From b95ce23f4915cf5021ddb90b796d0cec6afe8960 Mon Sep 17 00:00:00 2001 From: stilnat Date: Thu, 13 Nov 2025 18:12:26 +0100 Subject: [PATCH 01/87] add component based scaling gesture --- examples/lib/stories/input/scale_example.dart | 296 +++++++++++++++ my_tests/lib/main.dart | 53 +++ my_tests_2/.gitignore | 45 +++ my_tests_2/README.md | 16 + my_tests_2/analysis_options.yaml | 28 ++ my_tests_2/lib/main.dart | 296 +++++++++++++++ my_tests_2/pubspec.yaml | 91 +++++ my_tests_2/test/widget_test.dart | 30 ++ my_tests_2/web/favicon.png | Bin 0 -> 917 bytes my_tests_2/web/icons/Icon-192.png | Bin 0 -> 5292 bytes my_tests_2/web/icons/Icon-512.png | Bin 0 -> 8252 bytes my_tests_2/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes my_tests_2/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes my_tests_2/web/index.html | 38 ++ my_tests_2/web/manifest.json | 35 ++ packages/flame/lib/events.dart | 4 + .../component_mixins/scale_callbacks.dart | 22 ++ .../multi_drag_dispatcher.dart | 46 ++- .../multi_scale_dispatcher.dart | 343 ++++++++++++++++++ .../src/events/interfaces/scale_listener.dart | 31 ++ .../src/events/messages/scale_end_event.dart | 20 + .../events/messages/scale_start_event.dart | 35 ++ .../events/messages/scale_update_event.dart | 66 ++++ 23 files changed, 1491 insertions(+), 4 deletions(-) create mode 100644 examples/lib/stories/input/scale_example.dart create mode 100644 my_tests/lib/main.dart create mode 100644 my_tests_2/.gitignore create mode 100644 my_tests_2/README.md create mode 100644 my_tests_2/analysis_options.yaml create mode 100644 my_tests_2/lib/main.dart create mode 100644 my_tests_2/pubspec.yaml create mode 100644 my_tests_2/test/widget_test.dart create mode 100644 my_tests_2/web/favicon.png create mode 100644 my_tests_2/web/icons/Icon-192.png create mode 100644 my_tests_2/web/icons/Icon-512.png create mode 100644 my_tests_2/web/icons/Icon-maskable-192.png create mode 100644 my_tests_2/web/icons/Icon-maskable-512.png create mode 100644 my_tests_2/web/index.html create mode 100644 my_tests_2/web/manifest.json create mode 100644 packages/flame/lib/src/events/component_mixins/scale_callbacks.dart create mode 100644 packages/flame/lib/src/events/flame_game_mixins/multi_scale_dispatcher.dart create mode 100644 packages/flame/lib/src/events/interfaces/scale_listener.dart create mode 100644 packages/flame/lib/src/events/messages/scale_end_event.dart create mode 100644 packages/flame/lib/src/events/messages/scale_start_event.dart create mode 100644 packages/flame/lib/src/events/messages/scale_update_event.dart diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_example.dart new file mode 100644 index 00000000000..24f8967cff6 --- /dev/null +++ b/examples/lib/stories/input/scale_example.dart @@ -0,0 +1,296 @@ +import 'dart:math'; +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; + + +void main() { + runApp(GameWidget(game: ScaleExample())); +} + +class ScaleExample extends FlameGame { + late RectangleComponent rect; + late TextComponent debugText; + + late InteractiveRectangle interect; + Vector2 zoomCenter = Vector2.zero(); + double startingZoom = 1; + + final bool addScaleOnlyRectangle = true; + final bool addDragOnlyRectangle = true; + final bool addScaleDragRectangle = true; + final bool addZoom = false; + final bool addCameraRotation = false; + + @override + Future onLoad() async { + camera.viewfinder.zoom = 1; + + debugText = TextComponent( + text: 'hello', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 25, color: Colors.white), + ), + position: Vector2(50, 50), + ); + + if(addScaleOnlyRectangle){ + final interect2 = ScaleOnlyRectangle( + position: Vector2(0, 0), + size: Vector2.all(150), + ); + world.add(interect2); + } + if(addDragOnlyRectangle){ + final interect3 = DragOnlyRectangle( + position: Vector2(-200, -200), + size: Vector2.all(150), + color: Colors.green, + ); + world.add(interect3); + } + + if(addScaleDragRectangle){ + interect = InteractiveRectangle( + position: Vector2(200, 200), + size: Vector2.all(150), + color: Colors.red, + ); + world.add(interect); + } + + camera.viewport.add(debugText); + } + + @override + void update(double dt) { + super.update(dt); + + if(addCameraRotation){ + camera.viewfinder.angle += 0.001; + } + if(addZoom){ + debugText.text = '${camera.viewfinder.zoom}'; + camera.viewfinder.zoom += 0.001; + } + } +} + +/// A rectangle component that can respond to both drag and scale gestures. +class InteractiveRectangle extends RectangleComponent + with ScaleCallbacks, DragCallbacks, HasGameReference { + InteractiveRectangle({ + required Vector2 position, + required Vector2 size, + Color color = Colors.blue, + Anchor anchor = Anchor.center, + }) : super( + position: position, + size: size, + anchor: anchor, + paint: Paint()..color = color, + ); + + bool isScaling = false; + double initialAngle = 0; + Vector2 initialScale = Vector2.all(1); + double lastScale = 1.0; + + @override + Future onLoad() async { + final text = TextComponent( + text: 'drag + scale', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 25, color: Colors.white), + ), + position: size/2, + anchor: Anchor.center + ); + add(text); + } + + /// DragCallbacks overrides + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + debugPrint('Drag started at ${event.devicePosition}'); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + super.onDragUpdate(event); + if (isScaling){ + return; + } + final rotated = event.canvasDelta.clone() + ..rotate(game.camera.viewfinder.angle); + position.add(rotated); + } + + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + debugPrint('Drag ended with velocity ${event.velocity}'); + } + + /// ScaleCallbacks overrides + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + isScaling = true; + initialAngle = angle; + initialScale = scale; + lastScale = 1.0; + debugPrint('Scale started at ${event.devicePosition}'); + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + super.onScaleUpdate(event); + // scale rectangle size by pinch + angle = initialAngle + event.rotation; + + // delta scale since last frame + if (lastScale == 0) { + return; + } + final scaleDelta = event.scale / lastScale; + lastScale = event.scale; // update for next frame + + // apply delta gently + scale *= sqrt(scaleDelta); + + // clamp + scale.clamp(Vector2.all(0.8), Vector2.all(3)); + } + + @override + void onScaleEnd(ScaleEndEvent event) { + super.onScaleEnd(event); + isScaling = false; + debugPrint('Scale ended with velocity ${event.velocity}'); + } +} + +/// A rectangle that only responds to drag +class DragOnlyRectangle extends RectangleComponent + with DragCallbacks, HasGameReference { + DragOnlyRectangle({ + required Vector2 position, + required Vector2 size, + Color color = Colors.blue, + Anchor anchor = Anchor.center, + }) : super( + position: position, + size: size, + anchor: anchor, + paint: Paint()..color = color, + ); + + @override + Future onLoad() async { + final text = TextComponent( + text: 'drag', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 25, color: Colors.white), + ), + position: size/2, + anchor: Anchor.center + ); + add(text); + } + + /// DragCallbacks overrides + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + debugPrint('Drag started at ${event.devicePosition}'); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + super.onDragUpdate(event); + debugPrint('On Drag update'); + final rotated = event.canvasDelta.clone() + ..rotate(game.camera.viewfinder.angle); + position.add(rotated); + } + + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + debugPrint('Drag ended with velocity ${event.velocity}'); + } +} + +/// A rectangle that only responds to scale +class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { + ScaleOnlyRectangle({ + required Vector2 position, + required Vector2 size, + Color color = Colors.blue, + Anchor anchor = Anchor.center, + }) : super( + position: position, + size: size, + anchor: anchor, + paint: Paint()..color = color, + ); + + @override + Future onLoad() async { + final text = TextComponent( + text: 'scale', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 25, color: Colors.white), + ), + position: size/2, + anchor: Anchor.center + ); + add(text); + } + + bool 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}'); + } + + @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}'); + } +} + diff --git a/my_tests/lib/main.dart b/my_tests/lib/main.dart new file mode 100644 index 00000000000..89c9b2cfbea --- /dev/null +++ b/my_tests/lib/main.dart @@ -0,0 +1,53 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(GameWidget(game: ZoomTestGame())); +} + +class ZoomTestGame extends FlameGame with ScaleDetector { + late RectComponent rect; + Vector2 zoomCenter = Vector2.zero(); + + @override + Future onLoad() async { + rect = RectComponent( + position: size / 2, + size: Vector2.all(100), + anchor: Anchor.center, + paint: Paint()..color = Colors.blue, + ); + add(rect); + } + + @override + void onScaleStart(ScaleStartInfo info) { + zoomCenter = info.eventPosition.game; + } + + @override + void onScaleUpdate(ScaleUpdateInfo info) { + final zoomFactor = info.scale.global; + final before = zoomCenter - camera.position; + camera.zoom = (camera.zoom * zoomFactor).clamp(0.2, 5.0); + final after = zoomCenter - camera.position; + camera.position += before - after; + } +} + +class RectComponent extends PositionComponent { + final Paint paint; + RectComponent({ + required super.position, + required super.size, + required this.paint, + super.anchor, + }); + + @override + void render(Canvas canvas) { + canvas.drawRect(size.toRect(), paint); + } +} \ No newline at end of file diff --git a/my_tests_2/.gitignore b/my_tests_2/.gitignore new file mode 100644 index 00000000000..79c113f9b50 --- /dev/null +++ b/my_tests_2/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/my_tests_2/README.md b/my_tests_2/README.md new file mode 100644 index 00000000000..9d261625cc0 --- /dev/null +++ b/my_tests_2/README.md @@ -0,0 +1,16 @@ +# my_tests_2 + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/my_tests_2/analysis_options.yaml b/my_tests_2/analysis_options.yaml new file mode 100644 index 00000000000..0d2902135ca --- /dev/null +++ b/my_tests_2/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/my_tests_2/lib/main.dart b/my_tests_2/lib/main.dart new file mode 100644 index 00000000000..24f8967cff6 --- /dev/null +++ b/my_tests_2/lib/main.dart @@ -0,0 +1,296 @@ +import 'dart:math'; +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; + + +void main() { + runApp(GameWidget(game: ScaleExample())); +} + +class ScaleExample extends FlameGame { + late RectangleComponent rect; + late TextComponent debugText; + + late InteractiveRectangle interect; + Vector2 zoomCenter = Vector2.zero(); + double startingZoom = 1; + + final bool addScaleOnlyRectangle = true; + final bool addDragOnlyRectangle = true; + final bool addScaleDragRectangle = true; + final bool addZoom = false; + final bool addCameraRotation = false; + + @override + Future onLoad() async { + camera.viewfinder.zoom = 1; + + debugText = TextComponent( + text: 'hello', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 25, color: Colors.white), + ), + position: Vector2(50, 50), + ); + + if(addScaleOnlyRectangle){ + final interect2 = ScaleOnlyRectangle( + position: Vector2(0, 0), + size: Vector2.all(150), + ); + world.add(interect2); + } + if(addDragOnlyRectangle){ + final interect3 = DragOnlyRectangle( + position: Vector2(-200, -200), + size: Vector2.all(150), + color: Colors.green, + ); + world.add(interect3); + } + + if(addScaleDragRectangle){ + interect = InteractiveRectangle( + position: Vector2(200, 200), + size: Vector2.all(150), + color: Colors.red, + ); + world.add(interect); + } + + camera.viewport.add(debugText); + } + + @override + void update(double dt) { + super.update(dt); + + if(addCameraRotation){ + camera.viewfinder.angle += 0.001; + } + if(addZoom){ + debugText.text = '${camera.viewfinder.zoom}'; + camera.viewfinder.zoom += 0.001; + } + } +} + +/// A rectangle component that can respond to both drag and scale gestures. +class InteractiveRectangle extends RectangleComponent + with ScaleCallbacks, DragCallbacks, HasGameReference { + InteractiveRectangle({ + required Vector2 position, + required Vector2 size, + Color color = Colors.blue, + Anchor anchor = Anchor.center, + }) : super( + position: position, + size: size, + anchor: anchor, + paint: Paint()..color = color, + ); + + bool isScaling = false; + double initialAngle = 0; + Vector2 initialScale = Vector2.all(1); + double lastScale = 1.0; + + @override + Future onLoad() async { + final text = TextComponent( + text: 'drag + scale', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 25, color: Colors.white), + ), + position: size/2, + anchor: Anchor.center + ); + add(text); + } + + /// DragCallbacks overrides + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + debugPrint('Drag started at ${event.devicePosition}'); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + super.onDragUpdate(event); + if (isScaling){ + return; + } + final rotated = event.canvasDelta.clone() + ..rotate(game.camera.viewfinder.angle); + position.add(rotated); + } + + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + debugPrint('Drag ended with velocity ${event.velocity}'); + } + + /// ScaleCallbacks overrides + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + isScaling = true; + initialAngle = angle; + initialScale = scale; + lastScale = 1.0; + debugPrint('Scale started at ${event.devicePosition}'); + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + super.onScaleUpdate(event); + // scale rectangle size by pinch + angle = initialAngle + event.rotation; + + // delta scale since last frame + if (lastScale == 0) { + return; + } + final scaleDelta = event.scale / lastScale; + lastScale = event.scale; // update for next frame + + // apply delta gently + scale *= sqrt(scaleDelta); + + // clamp + scale.clamp(Vector2.all(0.8), Vector2.all(3)); + } + + @override + void onScaleEnd(ScaleEndEvent event) { + super.onScaleEnd(event); + isScaling = false; + debugPrint('Scale ended with velocity ${event.velocity}'); + } +} + +/// A rectangle that only responds to drag +class DragOnlyRectangle extends RectangleComponent + with DragCallbacks, HasGameReference { + DragOnlyRectangle({ + required Vector2 position, + required Vector2 size, + Color color = Colors.blue, + Anchor anchor = Anchor.center, + }) : super( + position: position, + size: size, + anchor: anchor, + paint: Paint()..color = color, + ); + + @override + Future onLoad() async { + final text = TextComponent( + text: 'drag', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 25, color: Colors.white), + ), + position: size/2, + anchor: Anchor.center + ); + add(text); + } + + /// DragCallbacks overrides + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + debugPrint('Drag started at ${event.devicePosition}'); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + super.onDragUpdate(event); + debugPrint('On Drag update'); + final rotated = event.canvasDelta.clone() + ..rotate(game.camera.viewfinder.angle); + position.add(rotated); + } + + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + debugPrint('Drag ended with velocity ${event.velocity}'); + } +} + +/// A rectangle that only responds to scale +class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { + ScaleOnlyRectangle({ + required Vector2 position, + required Vector2 size, + Color color = Colors.blue, + Anchor anchor = Anchor.center, + }) : super( + position: position, + size: size, + anchor: anchor, + paint: Paint()..color = color, + ); + + @override + Future onLoad() async { + final text = TextComponent( + text: 'scale', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 25, color: Colors.white), + ), + position: size/2, + anchor: Anchor.center + ); + add(text); + } + + bool 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}'); + } + + @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}'); + } +} + diff --git a/my_tests_2/pubspec.yaml b/my_tests_2/pubspec.yaml new file mode 100644 index 00000000000..b8886ec6346 --- /dev/null +++ b/my_tests_2/pubspec.yaml @@ -0,0 +1,91 @@ +name: my_tests_2 +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.8.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flame: + path: ../packages/flame + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/my_tests_2/test/widget_test.dart b/my_tests_2/test/widget_test.dart new file mode 100644 index 00000000000..a1243d0837b --- /dev/null +++ b/my_tests_2/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:my_tests_2/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/my_tests_2/web/favicon.png b/my_tests_2/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/my_tests_2/web/icons/Icon-192.png b/my_tests_2/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 zcmZ`-2T+sGz6~)*FVZ`aW+(v>MIm&M-g^@e2u-B-DoB?qO+b1Tq<5uCCv>ESfRum& zp%X;f!~1{tzL__3=gjVJ=j=J>+nMj%ncXj1Q(b|Ckbw{Y0FWpt%4y%$uD=Z*c-x~o zE;IoE;xa#7Ll5nj-e4CuXB&G*IM~D21rCP$*xLXAK8rIMCSHuSu%bL&S3)8YI~vyp@KBu9Ph7R_pvKQ@xv>NQ`dZp(u{Z8K3yOB zn7-AR+d2JkW)KiGx0hosml;+eCXp6+w%@STjFY*CJ?udJ64&{BCbuebcuH;}(($@@ znNlgBA@ZXB)mcl9nbX#F!f_5Z=W>0kh|UVWnf!At4V*LQP%*gPdCXd6P@J4Td;!Ur z<2ZLmwr(NG`u#gDEMP19UcSzRTL@HsK+PnIXbVBT@oHm53DZr?~V(0{rsalAfwgo zEh=GviaqkF;}F_5-yA!1u3!gxaR&Mj)hLuj5Q-N-@Lra{%<4ONja8pycD90&>yMB` zchhd>0CsH`^|&TstH-8+R`CfoWqmTTF_0?zDOY`E`b)cVi!$4xA@oO;SyOjJyP^_j zx^@Gdf+w|FW@DMdOi8=4+LJl$#@R&&=UM`)G!y%6ZzQLoSL%*KE8IO0~&5XYR9 z&N)?goEiWA(YoRfT{06&D6Yuu@Qt&XVbuW@COb;>SP9~aRc+z`m`80pB2o%`#{xD@ zI3RAlukL5L>px6b?QW1Ac_0>ew%NM!XB2(H+1Y3AJC?C?O`GGs`331Nd4ZvG~bMo{lh~GeL zSL|tT*fF-HXxXYtfu5z+T5Mx9OdP7J4g%@oeC2FaWO1D{=NvL|DNZ}GO?O3`+H*SI z=grGv=7dL{+oY0eJFGO!Qe(e2F?CHW(i!!XkGo2tUvsQ)I9ev`H&=;`N%Z{L zO?vV%rDv$y(@1Yj@xfr7Kzr<~0{^T8wM80xf7IGQF_S-2c0)0D6b0~yD7BsCy+(zL z#N~%&e4iAwi4F$&dI7x6cE|B{f@lY5epaDh=2-(4N05VO~A zQT3hanGy_&p+7Fb^I#ewGsjyCEUmSCaP6JDB*=_()FgQ(-pZ28-{qx~2foO4%pM9e z*_63RT8XjgiaWY|*xydf;8MKLd{HnfZ2kM%iq}fstImB-K6A79B~YoPVa@tYN@T_$ zea+9)<%?=Fl!kd(Y!G(-o}ko28hg2!MR-o5BEa_72uj7Mrc&{lRh3u2%Y=Xk9^-qa zBPWaD=2qcuJ&@Tf6ue&)4_V*45=zWk@Z}Q?f5)*z)-+E|-yC4fs5CE6L_PH3=zI8p z*Z3!it{1e5_^(sF*v=0{`U9C741&lub89gdhKp|Y8CeC{_{wYK-LSbp{h)b~9^j!s z7e?Y{Z3pZv0J)(VL=g>l;<}xk=T*O5YR|hg0eg4u98f2IrA-MY+StQIuK-(*J6TRR z|IM(%uI~?`wsfyO6Tgmsy1b3a)j6M&-jgUjVg+mP*oTKdHg?5E`!r`7AE_#?Fc)&a z08KCq>Gc=ne{PCbRvs6gVW|tKdcE1#7C4e`M|j$C5EYZ~Y=jUtc zj`+?p4ba3uy7><7wIokM79jPza``{Lx0)zGWg;FW1^NKY+GpEi=rHJ+fVRGfXO zPHV52k?jxei_!YYAw1HIz}y8ZMwdZqU%ESwMn7~t zdI5%B;U7RF=jzRz^NuY9nM)&<%M>x>0(e$GpU9th%rHiZsIT>_qp%V~ILlyt^V`=d z!1+DX@ah?RnB$X!0xpTA0}lN@9V-ePx>wQ?-xrJr^qDlw?#O(RsXeAvM%}rg0NT#t z!CsT;-vB=B87ShG`GwO;OEbeL;a}LIu=&@9cb~Rsx(ZPNQ!NT7H{@j0e(DiLea>QD zPmpe90gEKHEZ8oQ@6%E7k-Ptn#z)b9NbD@_GTxEhbS+}Bb74WUaRy{w;E|MgDAvHw zL)ycgM7mB?XVh^OzbC?LKFMotw3r@i&VdUV%^Efdib)3@soX%vWCbnOyt@Y4swW925@bt45y0HY3YI~BnnzZYrinFy;L?2D3BAL`UQ zEj))+f>H7~g8*VuWQ83EtGcx`hun$QvuurSMg3l4IP8Fe`#C|N6mbYJ=n;+}EQm;< z!!N=5j1aAr_uEnnzrEV%_E|JpTb#1p1*}5!Ce!R@d$EtMR~%9# zd;h8=QGT)KMW2IKu_fA_>p_und#-;Q)p%%l0XZOXQicfX8M~7?8}@U^ihu;mizj)t zgV7wk%n-UOb z#!P5q?Ex+*Kx@*p`o$q8FWL*E^$&1*!gpv?Za$YO~{BHeGY*5%4HXUKa_A~~^d z=E*gf6&+LFF^`j4$T~dR)%{I)T?>@Ma?D!gi9I^HqvjPc3-v~=qpX1Mne@*rzT&Xw zQ9DXsSV@PqpEJO-g4A&L{F&;K6W60D!_vs?Vx!?w27XbEuJJP&);)^+VF1nHqHBWu z^>kI$M9yfOY8~|hZ9WB!q-9u&mKhEcRjlf2nm_@s;0D#c|@ED7NZE% zzR;>P5B{o4fzlfsn3CkBK&`OSb-YNrqx@N#4CK!>bQ(V(D#9|l!e9(%sz~PYk@8zt zPN9oK78&-IL_F zhsk1$6p;GqFbtB^ZHHP+cjMvA0(LqlskbdYE_rda>gvQLTiqOQ1~*7lg%z*&p`Ry& zRcG^DbbPj_jOKHTr8uk^15Boj6>hA2S-QY(W-6!FIq8h$<>MI>PYYRenQDBamO#Fv zAH5&ImqKBDn0v5kb|8i0wFhUBJTpT!rB-`zK)^SNnRmLraZcPYK7b{I@+}wXVdW-{Ps17qdRA3JatEd?rPV z4@}(DAMf5EqXCr4-B+~H1P#;t@O}B)tIJ(W6$LrK&0plTmnPpb1TKn3?f?Kk``?D+ zQ!MFqOX7JbsXfQrz`-M@hq7xlfNz;_B{^wbpG8des56x(Q)H)5eLeDwCrVR}hzr~= zM{yXR6IM?kXxauLza#@#u?Y|o;904HCqF<8yT~~c-xyRc0-vxofnxG^(x%>bj5r}N zyFT+xnn-?B`ohA>{+ZZQem=*Xpqz{=j8i2TAC#x-m;;mo{{sLB_z(UoAqD=A#*juZ zCv=J~i*O8;F}A^Wf#+zx;~3B{57xtoxC&j^ie^?**T`WT2OPRtC`xj~+3Kprn=rVM zVJ|h5ux%S{dO}!mq93}P+h36mZ5aZg1-?vhL$ke1d52qIiXSE(llCr5i=QUS?LIjc zV$4q=-)aaR4wsrQv}^shL5u%6;`uiSEs<1nG^?$kl$^6DL z43CjY`M*p}ew}}3rXc7Xck@k41jx}c;NgEIhKZ*jsBRZUP-x2cm;F1<5$jefl|ppO zmZd%%?gMJ^g9=RZ^#8Mf5aWNVhjAS^|DQO+q$)oeob_&ZLFL(zur$)); zU19yRm)z<4&4-M}7!9+^Wl}Uk?`S$#V2%pQ*SIH5KI-mn%i;Z7-)m$mN9CnI$G7?# zo`zVrUwoSL&_dJ92YhX5TKqaRkfPgC4=Q&=K+;_aDs&OU0&{WFH}kKX6uNQC6%oUH z2DZa1s3%Vtk|bglbxep-w)PbFG!J17`<$g8lVhqD2w;Z0zGsh-r zxZ13G$G<48leNqR!DCVt9)@}(zMI5w6Wo=N zpP1*3DI;~h2WDWgcKn*f!+ORD)f$DZFwgKBafEZmeXQMAsq9sxP9A)7zOYnkHT9JU zRA`umgmP9d6=PHmFIgx=0$(sjb>+0CHG)K@cPG{IxaJ&Ueo8)0RWgV9+gO7+Bl1(F z7!BslJ2MP*PWJ;x)QXbR$6jEr5q3 z(3}F@YO_P1NyTdEXRLU6fp?9V2-S=E+YaeLL{Y)W%6`k7$(EW8EZSA*(+;e5@jgD^I zaJQ2|oCM1n!A&-8`;#RDcZyk*+RPkn_r8?Ak@agHiSp*qFNX)&i21HE?yuZ;-C<3C zwJGd1lx5UzViP7sZJ&|LqH*mryb}y|%AOw+v)yc`qM)03qyyrqhX?ub`Cjwx2PrR! z)_z>5*!*$x1=Qa-0uE7jy0z`>|Ni#X+uV|%_81F7)b+nf%iz=`fF4g5UfHS_?PHbr zB;0$bK@=di?f`dS(j{l3-tSCfp~zUuva+=EWxJcRfp(<$@vd(GigM&~vaYZ0c#BTs z3ijkxMl=vw5AS&DcXQ%eeKt!uKvh2l3W?&3=dBHU=Gz?O!40S&&~ei2vg**c$o;i89~6DVns zG>9a*`k5)NI9|?W!@9>rzJ;9EJ=YlJTx1r1BA?H`LWijk(rTax9(OAu;q4_wTj-yj z1%W4GW&K4T=uEGb+E!>W0SD_C0RR91 literal 0 HcmV?d00001 diff --git a/my_tests_2/web/icons/Icon-512.png b/my_tests_2/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/my_tests_2/web/icons/Icon-maskable-192.png b/my_tests_2/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/my_tests_2/web/icons/Icon-maskable-512.png b/my_tests_2/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/my_tests_2/web/index.html b/my_tests_2/web/index.html new file mode 100644 index 00000000000..886f7ac1215 --- /dev/null +++ b/my_tests_2/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + my_tests_2 + + + + + + diff --git a/my_tests_2/web/manifest.json b/my_tests_2/web/manifest.json new file mode 100644 index 00000000000..4843613780b --- /dev/null +++ b/my_tests_2/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "my_tests_2", + "short_name": "my_tests_2", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 1d7ccf21eba..8c1727ad370 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -4,6 +4,7 @@ export 'src/events/component_mixins/drag_callbacks.dart' show DragCallbacks; export 'src/events/component_mixins/hover_callbacks.dart' show HoverCallbacks; export 'src/events/component_mixins/pointer_move_callbacks.dart' show PointerMoveCallbacks; +export 'src/events/component_mixins/scale_callbacks.dart' show ScaleCallbacks; export 'src/events/component_mixins/secondary_tap_callbacks.dart' show SecondaryTapCallbacks; export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks; @@ -34,6 +35,9 @@ export 'src/events/messages/drag_end_event.dart' show DragEndEvent; export 'src/events/messages/drag_start_event.dart' show DragStartEvent; export 'src/events/messages/drag_update_event.dart' show DragUpdateEvent; export 'src/events/messages/pointer_move_event.dart' show PointerMoveEvent; +export 'src/events/messages/scale_end_event.dart' show ScaleEndEvent; +export 'src/events/messages/scale_start_event.dart' show ScaleStartEvent; +export 'src/events/messages/scale_update_event.dart' show ScaleUpdateEvent; export 'src/events/messages/secondary_tap_cancel_event.dart' show SecondaryTapCancelEvent; export 'src/events/messages/secondary_tap_down_event.dart' diff --git a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart new file mode 100644 index 00000000000..54e0e1828e8 --- /dev/null +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -0,0 +1,22 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/src/events/flame_game_mixins/multi_scale_dispatcher.dart'; +import 'package:flutter/foundation.dart'; + +mixin ScaleCallbacks on Component { + void onScaleStart(ScaleStartEvent event) {} + void onScaleUpdate(ScaleUpdateEvent event) {} + void onScaleEnd(ScaleEndEvent event) {} + + @override + @mustCallSuper + void onMount() { + super.onMount(); + final game = findRootGame()!; + if (game.findByKey(const MultiScaleDispatcherKey()) == null) { + final dispatcher = MultiScaleDispatcher(); + game.registerKey(const MultiScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + } + } +} 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 9dab4ba015f..53c0d928bd9 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,3 +1,5 @@ +import 'dart:async'; + import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/src/events/flame_drag_adapter.dart'; @@ -26,6 +28,30 @@ 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; /// Called when the user initiates a drag gesture, for example by touching the @@ -108,25 +134,33 @@ class MultiDragDispatcher extends Component implements MultiDragListener { @internal @override void handleDragStart(int pointerId, DragStartDetails details) { - onDragStart(DragStartEvent(pointerId, game, details)); + final event = DragStartEvent(pointerId, game, details); + onDragStart(event); + _dragStartController.add(event); } @internal @override void handleDragUpdate(int pointerId, DragUpdateDetails details) { - onDragUpdate(DragUpdateEvent(pointerId, game, details)); + final event = DragUpdateEvent(pointerId, game, details); + onDragUpdate(event); + _dragUpdateController.add(event); } @internal @override void handleDragEnd(int pointerId, DragEndDetails details) { - onDragEnd(DragEndEvent(pointerId, details)); + final event = DragEndEvent(pointerId, details); + onDragEnd(event); + _dragEndController.add(event); } @internal @override void handleDragCancel(int pointerId) { - onDragCancel(DragCancelEvent(pointerId)); + final event = DragCancelEvent(pointerId); + onDragCancel(event); + _dragCancelController.add(event); } //#endregion @@ -145,6 +179,10 @@ 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/multi_scale_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/multi_scale_dispatcher.dart new file mode 100644 index 00000000000..a34c375595c --- /dev/null +++ b/packages/flame/lib/src/events/flame_game_mixins/multi_scale_dispatcher.dart @@ -0,0 +1,343 @@ +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:flame/src/image_composition.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 [MultiScaleDispatcher] so the game can identify it. +class MultiScaleDispatcherKey implements ComponentKey { + const MultiScaleDispatcherKey(); + + @override + int get hashCode => 31650892; // arbitrary unique number + + @override + bool operator ==(Object other) => + other is MultiScaleDispatcherKey && other.hashCode == hashCode; +} + +/// A component that dispatches scale (pinch/zoom) events to components +/// implementing [ScaleCallbacks]. It will be attached to +/// the [FlameGame] instance automatically whenever any [ScaleCallbacks] +/// components are mounted into the component tree. +class MultiScaleDispatcher extends Component implements ScaleListener { + /// Records all components currently being scaled, keyed by pointerId. + final Set> _records = {}; + + FlameGame get game => parent! as FlameGame; + + /// Store the last drag events + DragStartDetails? lastDragStart; + DragUpdateDetails? lastDragUpdate; + DragEndDetails? lastDragEnd; + + _LineBetweenPointers? _currentLine; + + _LineBetweenPointers? _lineAtFirstUpdate; + + /// Called when the user starts a scale gesture. + @mustCallSuper + void onScaleStart(ScaleStartEvent event) { + event.deliverAtPoint( + rootComponent: game, + eventHandler: (ScaleCallbacks component) { + _records.add(TaggedComponent(event.pointerId, component)); + component.onScaleStart(event); + }, + ); + } + + /// Called continuously as the user updates the scale gesture. + @mustCallSuper + void onScaleUpdate(ScaleUpdateEvent event) { + final updated = >{}; + + // Deliver to components under the pointer + event.deliverAtPoint( + rootComponent: game, + deliverToAll: true, + eventHandler: (ScaleCallbacks component) { + final record = TaggedComponent(event.pointerId, component); + if (_records.contains(record)) { + component.onScaleUpdate(event); + updated.add(record); + } + }, + ); + + // Also deliver to components that started the scale but weren't under + // the pointer this frame + // TODO(stilnat): Currently, the id passed to the scale + // events is always 0, so maybe not relevant. + for (final record in _records) { + if (record.pointerId == event.pointerId && !updated.contains(record)) { + record.component.onScaleUpdate(event); + } + } + } + + /// Called when the scale gesture ends. + @mustCallSuper + void onScaleEnd(ScaleEndEvent event) { + _records.removeWhere((record) { + if (record.pointerId == event.pointerId) { + record.component.onScaleEnd(event); + return true; + } + return false; + }); + } + + //#region ScaleListener API + + @internal + @override + void handleScaleStart(ScaleStartDetails details) { + onScaleStart(ScaleStartEvent(0, game, details)); + } + + @internal + @override + void handleScaleUpdate(ScaleUpdateDetails details) { + if (details.pointerCount != 1) { + onScaleUpdate(ScaleUpdateEvent(0, game, details)); + return; + } + + final newDetails = _buildNewUpdateDetails(details); + if (newDetails != null) { + onScaleUpdate(ScaleUpdateEvent(0, game, newDetails)); + } + } + + /// If the user is doing a scale gesture, and we have more + /// than one pointer in contact with the screen, we don't have + /// anything special to do. + /// However, if the user is doing a scale gesture but a single pointer + /// is registered (such as when [ImmediateMultiDragGestureRecognizer] is + /// added to the [GestureDetectorBuilder] game gesture detectors), then + /// the [ScaleUpdateDetails] details won't contain any useful data, so + /// we need to rebuild it using data from the drag gesture. + ScaleUpdateDetails? _buildNewUpdateDetails(ScaleUpdateDetails details) { + if (lastDragUpdate == null) { + return null; + } + + _currentLine = _LineBetweenPointers( + pointerStartLocation: details.focalPoint, + pointerEndLocation: lastDragUpdate!.globalPosition, + ); + + // Register the line between the two pointers when the first update is + // triggered. This line will serve as a reference to compute scale + // and rotation data. + _lineAtFirstUpdate ??= _currentLine; + + // TODO(stilnat): 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 lenght of current line over lenght of initial line. + double _computeScale(ScaleUpdateDetails details) { + if (lastDragUpdate == null || + _currentLine == null || + _lineAtFirstUpdate == null) { + return 1.0; + } + + final currentLineDistance = _currentLine!.pointerStartLocation + .toVector2() + .distanceTo(_currentLine!.pointerEndLocation.toVector2()); + + final firstLineDistance = _lineAtFirstUpdate!.pointerStartLocation + .toVector2() + .distanceTo(_lineAtFirstUpdate!.pointerEndLocation.toVector2()); + + 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 lenght of current line vertical part over + /// lenght 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 lenght of current line horizontal part over + /// lenght of initial line part. + double _computeHorizontalScale(ScaleUpdateDetails details) { + if (lastDragUpdate == null || + _currentLine == null || + _lineAtFirstUpdate == null) { + return 1.0; + } + + final currentLineHorizontalDistance = + (_currentLine!.pointerStartLocation.dx - + _currentLine!.pointerEndLocation.dx) + .abs(); + final firstLineHorizontalDistance = + (_lineAtFirstUpdate!.pointerStartLocation.dx - + _lineAtFirstUpdate!.pointerEndLocation.dx) + .abs(); + + return currentLineHorizontalDistance / firstLineHorizontalDistance; + } + + @internal + @override + void handleScaleEnd(ScaleEndDetails details) { + _currentLine = null; + _lineAtFirstUpdate = null; + onScaleEnd(ScaleEndEvent(0, details)); + } + + //#endregion + + @override + void onMount() { + game.gestureDetectors.add( + ScaleGestureRecognizer.new, + (ScaleGestureRecognizer instance) { + instance + ..onStart = handleScaleStart + ..onUpdate = handleScaleUpdate + ..onEnd = handleScaleEnd; + }, + ); + final existingDispatcher = game.findByKey(const MultiDragDispatcherKey()); + if (existingDispatcher != null) { + listenToDragDispatcher(existingDispatcher as MultiDragDispatcher); + } else { + // If the MultiDragDispatcher wasn't already added we add it + // ourselves here. + final game = findRootGame()!; + final dispatcher = MultiDragDispatcher(); + game.registerKey(const MultiDragDispatcherKey(), dispatcher); + game.add(dispatcher); + listenToDragDispatcher(dispatcher); + } + + super.onMount(); + } + + @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/interfaces/scale_listener.dart b/packages/flame/lib/src/events/interfaces/scale_listener.dart new file mode 100644 index 00000000000..e03b783687e --- /dev/null +++ b/packages/flame/lib/src/events/interfaces/scale_listener.dart @@ -0,0 +1,31 @@ +import 'package:flame/events.dart'; +import 'package:flutter/gestures.dart'; + +/// Interface that must be implemented by a game in order for it to be eligible +/// to receive events from an [ScaleGestureRecognizer]. +/// +/// Instead of implementing this class directly consider using one of the +/// prebuilt mixins: +/// - [MultiTouchDragDetector] for a custom `Game` +abstract class ScaleListener { + /// The beginning of a drag operation. + /// + /// If the game is not listening to tap events, this event will occur as soon + /// as the user touches the screen. If the game uses both a [MultiTapListener] + /// and a [MultiDragListener] simultaneously, then this event will fire once + /// the user moves their finger away from the point of the initial touch. + void handleScaleStart(ScaleStartDetails details); + + /// The pointer that was touching the screen has moved. + /// + /// This event occurs frequently during the drag, allowing you to keep track + /// of the position of the point of touch as it moves. This event will only + /// fire when the point of touch moves, and not when it stays still. + void handleScaleUpdate(ScaleUpdateDetails details); + + /// Marks the end of a drag operation. + /// + /// This event fires when the pointer stops touching the screen. It will fire + /// even if the pointer is currently outside of the game widget. + void handleScaleEnd(ScaleEndDetails details); +} diff --git a/packages/flame/lib/src/events/messages/scale_end_event.dart b/packages/flame/lib/src/events/messages/scale_end_event.dart new file mode 100644 index 00000000000..997590ee3b5 --- /dev/null +++ b/packages/flame/lib/src/events/messages/scale_end_event.dart @@ -0,0 +1,20 @@ +import 'package:flame/extensions.dart'; +import 'package:flame/src/events/messages/event.dart'; +import 'package:flutter/gestures.dart'; + +/// Event propagated through the Flame engine when a scale gesture ends. +class ScaleEndEvent extends Event { + ScaleEndEvent(this.pointerId, ScaleEndDetails details) + : velocity = details.velocity.pixelsPerSecond.toVector2(), + super(raw: details); + + /// The unique identifier of the scale gesture. + final int pointerId; + + /// The velocity of the fingers at the end of the scale gesture. + final Vector2 velocity; + + @override + String toString() => + 'ScaleEndEvent(pointerId: $pointerId, velocity: $velocity)'; +} diff --git a/packages/flame/lib/src/events/messages/scale_start_event.dart b/packages/flame/lib/src/events/messages/scale_start_event.dart new file mode 100644 index 00000000000..1bce4732e28 --- /dev/null +++ b/packages/flame/lib/src/events/messages/scale_start_event.dart @@ -0,0 +1,35 @@ +import 'package:flame/events.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame/src/events/messages/position_event.dart'; +import 'package:flutter/gestures.dart'; + +/// The event propagated through the Flame engine when the user starts a scale +/// (pinch/zoom) gesture on the game canvas. +/// +/// This is a [PositionEvent], where the position is the focal point of the +/// gesture. +class ScaleStartEvent extends PositionEvent { + ScaleStartEvent(this.pointerId, super.game, ScaleStartDetails details) + : deviceKind = details.kind ?? PointerDeviceKind.unknown, + super( + raw: details, + devicePosition: details.focalPoint.toVector2(), + ); + + /// The unique identifier of the scale event. + /// + /// Subsequent [ScaleUpdateEvent] or [ScaleEndEvent] will carry the same + /// pointer id. This allows distinguishing multiple simultaneous scale + /// gestures. + /// + final int pointerId; + + /// The type of device that initiated the gesture. + final PointerDeviceKind deviceKind; + + @override + String toString() => + 'ScaleStartEvent(canvasPosition: $canvasPosition, ' + 'devicePosition: $devicePosition, ' + 'pointerId: $pointerId, deviceKind: $deviceKind)'; +} diff --git a/packages/flame/lib/src/events/messages/scale_update_event.dart b/packages/flame/lib/src/events/messages/scale_update_event.dart new file mode 100644 index 00000000000..9766ac66ac7 --- /dev/null +++ b/packages/flame/lib/src/events/messages/scale_update_event.dart @@ -0,0 +1,66 @@ +import 'package:flame/extensions.dart'; +import 'package:flame/src/events/messages/displacement_event.dart'; +import 'package:flutter/gestures.dart'; + +/// Event propagated through the Flame engine when the user updates a scale +/// (pinch/zoom/rotate) gesture on the game canvas. +class ScaleUpdateEvent extends DisplacementEvent { + ScaleUpdateEvent( + this.pointerId, + super.game, + ScaleUpdateDetails details, + ) : scale = details.scale, + horizontalScale = details.horizontalScale, + verticalScale = details.verticalScale, + rotation = details.rotation, + pointerCount = details.pointerCount, + focalPointDelta = details.focalPointDelta.toVector2(), + timestamp = details.sourceTimeStamp ?? Duration.zero, + super( + raw: details, + deviceStartPosition: details.focalPoint.toVector2(), + deviceEndPosition: + details.focalPoint.toVector2() + + details.focalPointDelta.toVector2(), + ); + + /// Unique identifier of this scale gesture (Flame-level) + final int pointerId; + + /// The instantaneous 2D scale factor (global) + final double scale; + + /// Horizontal-only scale factor + final double horizontalScale; + + /// Vertical-only scale factor + final double verticalScale; + + /// Rotation delta in radians + final double rotation; + + /// Number of fingers detected during this update + final int pointerCount; + + /// Movement of the pinch center since last frame + final Vector2 focalPointDelta; + + /// Timestamp for ordering/debugging + final Duration timestamp; + + @override + String toString() => + 'ScaleUpdateEvent(' + 'pointerId: $pointerId, ' + 'scale: $scale, ' + 'hScale: $horizontalScale, ' + 'vScale: $verticalScale, ' + 'rotation: $rotation, ' + 'pointerCount: $pointerCount, ' + 'focalPointDelta: $focalPointDelta, ' + 'deviceStartPosition: $deviceStartPosition, ' + 'deviceEndPosition: $deviceEndPosition, ' + 'localDelta: $localDelta, ' + 'timestamp: $timestamp' + ')'; +} From 4b8bc31e5464ad48df81dc731831e19295896aa9 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 15 Nov 2025 23:00:23 +0100 Subject: [PATCH 02/87] rename multi scale to scale --- .../lib/src/events/component_mixins/scale_callbacks.dart | 8 ++++---- ...{multi_scale_dispatcher.dart => scale_dispatcher.dart} | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) rename packages/flame/lib/src/events/flame_game_mixins/{multi_scale_dispatcher.dart => scale_dispatcher.dart} (98%) 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 54e0e1828e8..51108ddad4e 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -1,6 +1,6 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; -import 'package:flame/src/events/flame_game_mixins/multi_scale_dispatcher.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flutter/foundation.dart'; mixin ScaleCallbacks on Component { @@ -13,9 +13,9 @@ mixin ScaleCallbacks on Component { void onMount() { super.onMount(); final game = findRootGame()!; - if (game.findByKey(const MultiScaleDispatcherKey()) == null) { - final dispatcher = MultiScaleDispatcher(); - game.registerKey(const MultiScaleDispatcherKey(), dispatcher); + if (game.findByKey(const ScaleDispatcherKey()) == null) { + final dispatcher = ScaleDispatcher(); + game.registerKey(const ScaleDispatcherKey(), dispatcher); game.add(dispatcher); } } diff --git a/packages/flame/lib/src/events/flame_game_mixins/multi_scale_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart similarity index 98% rename from packages/flame/lib/src/events/flame_game_mixins/multi_scale_dispatcher.dart rename to packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart index a34c375595c..2c449830a21 100644 --- a/packages/flame/lib/src/events/flame_game_mixins/multi_scale_dispatcher.dart +++ b/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart @@ -32,22 +32,22 @@ class _LineBetweenPointers { } /// Unique key for the [MultiScaleDispatcher] so the game can identify it. -class MultiScaleDispatcherKey implements ComponentKey { - const MultiScaleDispatcherKey(); +class ScaleDispatcherKey implements ComponentKey { + const ScaleDispatcherKey(); @override int get hashCode => 31650892; // arbitrary unique number @override bool operator ==(Object other) => - other is MultiScaleDispatcherKey && other.hashCode == hashCode; + other is ScaleDispatcherKey && other.hashCode == hashCode; } /// A component that dispatches scale (pinch/zoom) events to components /// implementing [ScaleCallbacks]. It will be attached to /// the [FlameGame] instance automatically whenever any [ScaleCallbacks] /// components are mounted into the component tree. -class MultiScaleDispatcher extends Component implements ScaleListener { +class ScaleDispatcher extends Component implements ScaleListener { /// Records all components currently being scaled, keyed by pointerId. final Set> _records = {}; From 487e579ee7e6094de1f2365cca95b81dcdec44c0 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 15 Nov 2025 23:37:18 +0100 Subject: [PATCH 03/87] Delete my_tests_2 directory --- my_tests_2/.gitignore | 45 ---- my_tests_2/README.md | 16 -- my_tests_2/analysis_options.yaml | 28 -- my_tests_2/lib/main.dart | 296 --------------------- my_tests_2/pubspec.yaml | 91 ------- my_tests_2/test/widget_test.dart | 30 --- my_tests_2/web/favicon.png | Bin 917 -> 0 bytes my_tests_2/web/icons/Icon-192.png | Bin 5292 -> 0 bytes my_tests_2/web/icons/Icon-512.png | Bin 8252 -> 0 bytes my_tests_2/web/icons/Icon-maskable-192.png | Bin 5594 -> 0 bytes my_tests_2/web/icons/Icon-maskable-512.png | Bin 20998 -> 0 bytes my_tests_2/web/index.html | 38 --- my_tests_2/web/manifest.json | 35 --- 13 files changed, 579 deletions(-) delete mode 100644 my_tests_2/.gitignore delete mode 100644 my_tests_2/README.md delete mode 100644 my_tests_2/analysis_options.yaml delete mode 100644 my_tests_2/lib/main.dart delete mode 100644 my_tests_2/pubspec.yaml delete mode 100644 my_tests_2/test/widget_test.dart delete mode 100644 my_tests_2/web/favicon.png delete mode 100644 my_tests_2/web/icons/Icon-192.png delete mode 100644 my_tests_2/web/icons/Icon-512.png delete mode 100644 my_tests_2/web/icons/Icon-maskable-192.png delete mode 100644 my_tests_2/web/icons/Icon-maskable-512.png delete mode 100644 my_tests_2/web/index.html delete mode 100644 my_tests_2/web/manifest.json diff --git a/my_tests_2/.gitignore b/my_tests_2/.gitignore deleted file mode 100644 index 79c113f9b50..00000000000 --- a/my_tests_2/.gitignore +++ /dev/null @@ -1,45 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/my_tests_2/README.md b/my_tests_2/README.md deleted file mode 100644 index 9d261625cc0..00000000000 --- a/my_tests_2/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# my_tests_2 - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/my_tests_2/analysis_options.yaml b/my_tests_2/analysis_options.yaml deleted file mode 100644 index 0d2902135ca..00000000000 --- a/my_tests_2/analysis_options.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/my_tests_2/lib/main.dart b/my_tests_2/lib/main.dart deleted file mode 100644 index 24f8967cff6..00000000000 --- a/my_tests_2/lib/main.dart +++ /dev/null @@ -1,296 +0,0 @@ -import 'dart:math'; -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/game.dart'; -import 'package:flutter/material.dart'; - - -void main() { - runApp(GameWidget(game: ScaleExample())); -} - -class ScaleExample extends FlameGame { - late RectangleComponent rect; - late TextComponent debugText; - - late InteractiveRectangle interect; - Vector2 zoomCenter = Vector2.zero(); - double startingZoom = 1; - - final bool addScaleOnlyRectangle = true; - final bool addDragOnlyRectangle = true; - final bool addScaleDragRectangle = true; - final bool addZoom = false; - final bool addCameraRotation = false; - - @override - Future onLoad() async { - camera.viewfinder.zoom = 1; - - debugText = TextComponent( - text: 'hello', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: Vector2(50, 50), - ); - - if(addScaleOnlyRectangle){ - final interect2 = ScaleOnlyRectangle( - position: Vector2(0, 0), - size: Vector2.all(150), - ); - world.add(interect2); - } - if(addDragOnlyRectangle){ - final interect3 = DragOnlyRectangle( - position: Vector2(-200, -200), - size: Vector2.all(150), - color: Colors.green, - ); - world.add(interect3); - } - - if(addScaleDragRectangle){ - interect = InteractiveRectangle( - position: Vector2(200, 200), - size: Vector2.all(150), - color: Colors.red, - ); - world.add(interect); - } - - camera.viewport.add(debugText); - } - - @override - void update(double dt) { - super.update(dt); - - if(addCameraRotation){ - camera.viewfinder.angle += 0.001; - } - if(addZoom){ - debugText.text = '${camera.viewfinder.zoom}'; - camera.viewfinder.zoom += 0.001; - } - } -} - -/// A rectangle component that can respond to both drag and scale gestures. -class InteractiveRectangle extends RectangleComponent - with ScaleCallbacks, DragCallbacks, HasGameReference { - InteractiveRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); - - bool isScaling = false; - double initialAngle = 0; - Vector2 initialScale = Vector2.all(1); - double lastScale = 1.0; - - @override - Future onLoad() async { - final text = TextComponent( - text: 'drag + scale', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size/2, - anchor: Anchor.center - ); - add(text); - } - - /// DragCallbacks overrides - @override - void onDragStart(DragStartEvent event) { - super.onDragStart(event); - debugPrint('Drag started at ${event.devicePosition}'); - } - - @override - void onDragUpdate(DragUpdateEvent event) { - super.onDragUpdate(event); - if (isScaling){ - return; - } - final rotated = event.canvasDelta.clone() - ..rotate(game.camera.viewfinder.angle); - position.add(rotated); - } - - @override - void onDragEnd(DragEndEvent event) { - super.onDragEnd(event); - debugPrint('Drag ended with velocity ${event.velocity}'); - } - - /// ScaleCallbacks overrides - @override - void onScaleStart(ScaleStartEvent event) { - super.onScaleStart(event); - isScaling = true; - initialAngle = angle; - initialScale = scale; - lastScale = 1.0; - debugPrint('Scale started at ${event.devicePosition}'); - } - - @override - void onScaleUpdate(ScaleUpdateEvent event) { - super.onScaleUpdate(event); - // scale rectangle size by pinch - angle = initialAngle + event.rotation; - - // delta scale since last frame - if (lastScale == 0) { - return; - } - final scaleDelta = event.scale / lastScale; - lastScale = event.scale; // update for next frame - - // apply delta gently - scale *= sqrt(scaleDelta); - - // clamp - scale.clamp(Vector2.all(0.8), Vector2.all(3)); - } - - @override - void onScaleEnd(ScaleEndEvent event) { - super.onScaleEnd(event); - isScaling = false; - debugPrint('Scale ended with velocity ${event.velocity}'); - } -} - -/// A rectangle that only responds to drag -class DragOnlyRectangle extends RectangleComponent - with DragCallbacks, HasGameReference { - DragOnlyRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); - - @override - Future onLoad() async { - final text = TextComponent( - text: 'drag', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size/2, - anchor: Anchor.center - ); - add(text); - } - - /// DragCallbacks overrides - @override - void onDragStart(DragStartEvent event) { - super.onDragStart(event); - debugPrint('Drag started at ${event.devicePosition}'); - } - - @override - void onDragUpdate(DragUpdateEvent event) { - super.onDragUpdate(event); - debugPrint('On Drag update'); - final rotated = event.canvasDelta.clone() - ..rotate(game.camera.viewfinder.angle); - position.add(rotated); - } - - @override - void onDragEnd(DragEndEvent event) { - super.onDragEnd(event); - debugPrint('Drag ended with velocity ${event.velocity}'); - } -} - -/// A rectangle that only responds to scale -class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { - ScaleOnlyRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); - - @override - Future onLoad() async { - final text = TextComponent( - text: 'scale', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size/2, - anchor: Anchor.center - ); - add(text); - } - - bool 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}'); - } - - @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}'); - } -} - diff --git a/my_tests_2/pubspec.yaml b/my_tests_2/pubspec.yaml deleted file mode 100644 index b8886ec6346..00000000000 --- a/my_tests_2/pubspec.yaml +++ /dev/null @@ -1,91 +0,0 @@ -name: my_tests_2 -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 - -environment: - sdk: ^3.8.1 - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - flame: - path: ../packages/flame - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^5.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/my_tests_2/test/widget_test.dart b/my_tests_2/test/widget_test.dart deleted file mode 100644 index a1243d0837b..00000000000 --- a/my_tests_2/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:my_tests_2/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/my_tests_2/web/favicon.png b/my_tests_2/web/favicon.png deleted file mode 100644 index 8aaa46ac1ae21512746f852a42ba87e4165dfdd1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM diff --git a/my_tests_2/web/icons/Icon-192.png b/my_tests_2/web/icons/Icon-192.png deleted file mode 100644 index b749bfef07473333cf1dd31e9eed89862a5d52aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5292 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 diff --git a/my_tests_2/web/icons/Icon-512.png b/my_tests_2/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48dff1169879ba46840804b412fe02fefd6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8252 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 diff --git a/my_tests_2/web/icons/Icon-maskable-192.png b/my_tests_2/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d76e525556d5d89141648c724331630325d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! diff --git a/my_tests_2/web/icons/Icon-maskable-512.png b/my_tests_2/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c56691fbdb0b7efa65097c7cc1edac12a6d3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20998 zcmeFZ_gj-)&^4Nb2tlbLMU<{!p(#yjqEe+=0IA_oih%ScH9@5#MNp&}Y#;;(h=A0@ zh7{>lT2MkSQ344eAvrhici!td|HJuyvJm#Y_w1Q9Yu3!26dNlO-oxUDK_C#XnW^Co z5C{VN6#{~B0)K2j7}*1Xq(Nqemv23A-6&=ZpEijkVnSwVGqLv40?n0=p;k3-U5e5+ z+z3>aS`u9DS=!wg8ROu?X4TFoW6CFLL&{GzoVT)ldhLekLM|+j3tIxRd|*5=c{=s&*vfPdBr(Fyj(v@%eQj1Soy7m4^@VRl1~@-PV7y+c!xz$8436WBn$t{=}mEdK#k`aystimGgI{(IBx$!pAwFoE9Y`^t^;> zKAD)C(Dl^s%`?q5$P|fZf8Xymrtu^Pv(7D`rn>Z-w$Ahs!z9!94WNVxrJuXfHAaxg zC6s@|Z1$7R$(!#t%Jb{{s6(Y?NoQXDYq)!}X@jKPhe`{9KQ@sAU8y-5`xt?S9$jKH zoi}6m5PcG*^{kjvt+kwPpyQzVg4o)a>;LK`aaN2x4@itBD3Aq?yWTM20VRn1rrd+2 zKO=P0rMjEGq_UqpMa`~7B|p?xAN1SCoCp}QxAv8O`jLJ5CVh@umR%c%i^)6!o+~`F zaalSTQcl5iwOLC&H)efzd{8(88mo`GI(56T<(&p7>Qd^;R1hn1Y~jN~tApaL8>##U zd65bo8)79CplWxr#z4!6HvLz&N7_5AN#x;kLG?zQ(#p|lj<8VUlKY=Aw!ATqeL-VG z42gA!^cMNPj>(`ZMEbCrnkg*QTsn*u(nQPWI9pA{MQ=IsPTzd7q5E#7+z>Ch=fx$~ z;J|?(5jTo5UWGvsJa(Sx0?S#56+8SD!I^tftyeh_{5_31l6&Hywtn`bbqYDqGZXI( zCG7hBgvksX2ak8+)hB4jnxlO@A32C_RM&g&qDSb~3kM&)@A_j1*oTO@nicGUyv+%^ z=vB)4(q!ykzT==Z)3*3{atJ5}2PV*?Uw+HhN&+RvKvZL3p9E?gHjv{6zM!A|z|UHK z-r6jeLxbGn0D@q5aBzlco|nG2tr}N@m;CJX(4#Cn&p&sLKwzLFx1A5izu?X_X4x8r@K*d~7>t1~ zDW1Mv5O&WOxbzFC`DQ6yNJ(^u9vJdj$fl2dq`!Yba_0^vQHXV)vqv1gssZYzBct!j zHr9>ydtM8wIs}HI4=E}qAkv|BPWzh3^_yLH(|kdb?x56^BlDC)diWyPd*|f!`^12_U>TD^^94OCN0lVv~Sgvs94ecpE^}VY$w`qr_>Ue zTfH~;C<3H<0dS5Rkf_f@1x$Gms}gK#&k()IC0zb^QbR!YLoll)c$Agfi6MKI0dP_L z=Uou&u~~^2onea2%XZ@>`0x^L8CK6=I{ge;|HXMj)-@o~h&O{CuuwBX8pVqjJ*o}5 z#8&oF_p=uSo~8vn?R0!AMWvcbZmsrj{ZswRt(aEdbi~;HeVqIe)-6*1L%5u$Gbs}| zjFh?KL&U(rC2izSGtwP5FnsR@6$-1toz?RvLD^k~h9NfZgzHE7m!!7s6(;)RKo2z} zB$Ci@h({l?arO+vF;s35h=|WpefaOtKVx>l399}EsX@Oe3>>4MPy%h&^3N_`UTAHJ zI$u(|TYC~E4)|JwkWW3F!Tib=NzjHs5ii2uj0^m|Qlh-2VnB#+X~RZ|`SA*}}&8j9IDv?F;(Y^1=Z0?wWz;ikB zewU>MAXDi~O7a~?jx1x=&8GcR-fTp>{2Q`7#BE#N6D@FCp`?ht-<1|y(NArxE_WIu zP+GuG=Qq>SHWtS2M>34xwEw^uvo4|9)4s|Ac=ud?nHQ>ax@LvBqusFcjH0}{T3ZPQ zLO1l<@B_d-(IS682}5KA&qT1+{3jxKolW+1zL4inqBS-D>BohA!K5++41tM@ z@xe<-qz27}LnV#5lk&iC40M||JRmZ*A##K3+!j93eouU8@q-`W0r%7N`V$cR&JV;iX(@cS{#*5Q>~4BEDA)EikLSP@>Oo&Bt1Z~&0d5)COI%3$cLB_M?dK# z{yv2OqW!al-#AEs&QFd;WL5zCcp)JmCKJEdNsJlL9K@MnPegK23?G|O%v`@N{rIRa zi^7a}WBCD77@VQ-z_v{ZdRsWYrYgC$<^gRQwMCi6);%R~uIi31OMS}=gUTE(GKmCI z$zM>mytL{uNN+a&S38^ez(UT=iSw=l2f+a4)DyCA1Cs_N-r?Q@$3KTYosY!;pzQ0k zzh1G|kWCJjc(oZVBji@kN%)UBw(s{KaYGy=i{g3{)Z+&H8t2`^IuLLKWT6lL<-C(! zSF9K4xd-|VO;4}$s?Z7J_dYqD#Mt)WCDnsR{Kpjq275uUq6`v0y*!PHyS(}Zmv)_{>Vose9-$h8P0|y;YG)Bo}$(3Z%+Gs0RBmFiW!^5tBmDK-g zfe5%B*27ib+7|A*Fx5e)2%kIxh7xWoc3pZcXS2zik!63lAG1;sC1ja>BqH7D zODdi5lKW$$AFvxgC-l-)!c+9@YMC7a`w?G(P#MeEQ5xID#<}W$3bSmJ`8V*x2^3qz zVe<^^_8GHqYGF$nIQm0Xq2kAgYtm#UC1A(=&85w;rmg#v906 zT;RyMgbMpYOmS&S9c38^40oUp?!}#_84`aEVw;T;r%gTZkWeU;;FwM@0y0adt{-OK z(vGnPSlR=Nv2OUN!2=xazlnHPM9EWxXg2EKf0kI{iQb#FoP>xCB<)QY>OAM$Dcdbm zU6dU|%Mo(~avBYSjRc13@|s>axhrPl@Sr81{RSZUdz4(=|82XEbV*JAX6Lfbgqgz584lYgi0 z2-E{0XCVON$wHfvaLs;=dqhQJ&6aLn$D#0i(FkAVrXG9LGm3pSTf&f~RQb6|1_;W> z?n-;&hrq*~L=(;u#jS`*Yvh@3hU-33y_Kv1nxqrsf>pHVF&|OKkoC)4DWK%I!yq?P z=vXo8*_1iEWo8xCa{HJ4tzxOmqS0&$q+>LroMKI*V-rxhOc%3Y!)Y|N6p4PLE>Yek>Y(^KRECg8<|%g*nQib_Yc#A5q8Io z6Ig&V>k|~>B6KE%h4reAo*DfOH)_01tE0nWOxX0*YTJgyw7moaI^7gW*WBAeiLbD?FV9GSB zPv3`SX*^GRBM;zledO`!EbdBO_J@fEy)B{-XUTVQv}Qf~PSDpK9+@I`7G7|>Dgbbu z_7sX9%spVo$%qwRwgzq7!_N;#Td08m5HV#?^dF-EV1o)Q=Oa+rs2xH#g;ykLbwtCh znUnA^dW!XjspJ;otq$yV@I^s9Up(5k7rqhQd@OLMyyxVLj_+$#Vc*}Usevp^I(^vH zmDgHc0VMme|K&X?9&lkN{yq_(If)O`oUPW8X}1R5pSVBpfJe0t{sPA(F#`eONTh_) zxeLqHMfJX#?P(@6w4CqRE@Eiza; z;^5)Kk=^5)KDvd9Q<`=sJU8rjjxPmtWMTmzcH={o$U)j=QBuHarp?=}c??!`3d=H$nrJMyr3L-& zA#m?t(NqLM?I3mGgWA_C+0}BWy3-Gj7bR+d+U?n*mN$%5P`ugrB{PeV>jDUn;eVc- zzeMB1mI4?fVJatrNyq|+zn=!AiN~<}eoM#4uSx^K?Iw>P2*r=k`$<3kT00BE_1c(02MRz4(Hq`L^M&xt!pV2 zn+#U3@j~PUR>xIy+P>51iPayk-mqIK_5rlQMSe5&tDkKJk_$i(X&;K(11YGpEc-K= zq4Ln%^j>Zi_+Ae9eYEq_<`D+ddb8_aY!N;)(&EHFAk@Ekg&41ABmOXfWTo)Z&KotA zh*jgDGFYQ^y=m)<_LCWB+v48DTJw*5dwMm_YP0*_{@HANValf?kV-Ic3xsC}#x2h8 z`q5}d8IRmqWk%gR)s~M}(Qas5+`np^jW^oEd-pzERRPMXj$kS17g?H#4^trtKtq;C?;c ztd|%|WP2w2Nzg@)^V}!Gv++QF2!@FP9~DFVISRW6S?eP{H;;8EH;{>X_}NGj^0cg@ z!2@A>-CTcoN02^r6@c~^QUa={0xwK0v4i-tQ9wQq^=q*-{;zJ{Qe%7Qd!&X2>rV@4 z&wznCz*63_vw4>ZF8~%QCM?=vfzW0r_4O^>UA@otm_!N%mH)!ERy&b!n3*E*@?9d^ zu}s^By@FAhG(%?xgJMuMzuJw2&@$-oK>n z=UF}rt%vuaP9fzIFCYN-1&b#r^Cl6RDFIWsEsM|ROf`E?O(cy{BPO2Ie~kT+^kI^i zp>Kbc@C?}3vy-$ZFVX#-cx)Xj&G^ibX{pWggtr(%^?HeQL@Z( zM-430g<{>vT*)jK4aY9(a{lSy{8vxLbP~n1MXwM527ne#SHCC^F_2@o`>c>>KCq9c(4c$VSyMl*y3Nq1s+!DF| z^?d9PipQN(mw^j~{wJ^VOXDCaL$UtwwTpyv8IAwGOg<|NSghkAR1GSNLZ1JwdGJYm zP}t<=5=sNNUEjc=g(y)1n5)ynX(_$1-uGuDR*6Y^Wgg(LT)Jp><5X|}bt z_qMa&QP?l_n+iVS>v%s2Li_;AIeC=Ca^v1jX4*gvB$?H?2%ndnqOaK5-J%7a} zIF{qYa&NfVY}(fmS0OmXA70{znljBOiv5Yod!vFU{D~*3B3Ka{P8?^ zfhlF6o7aNT$qi8(w<}OPw5fqA7HUje*r*Oa(YV%*l0|9FP9KW@U&{VSW{&b0?@y)M zs%4k1Ax;TGYuZ9l;vP5@?3oQsp3)rjBeBvQQ>^B;z5pc=(yHhHtq6|0m(h4envn_j787fizY@V`o(!SSyE7vlMT zbo=Z1c=atz*G!kwzGB;*uPL$Ei|EbZLh8o+1BUMOpnU(uX&OG1MV@|!&HOOeU#t^x zr9=w2ow!SsTuJWT7%Wmt14U_M*3XiWBWHxqCVZI0_g0`}*^&yEG9RK9fHK8e+S^m? zfCNn$JTswUVbiC#>|=wS{t>-MI1aYPLtzO5y|LJ9nm>L6*wpr_m!)A2Fb1RceX&*|5|MwrvOk4+!0p99B9AgP*9D{Yt|x=X}O% zgIG$MrTB=n-!q%ROT|SzH#A$Xm;|ym)0>1KR}Yl0hr-KO&qMrV+0Ej3d@?FcgZ+B3 ztEk16g#2)@x=(ko8k7^Tq$*5pfZHC@O@}`SmzT1(V@x&NkZNM2F#Q-Go7-uf_zKC( zB(lHZ=3@dHaCOf6C!6i8rDL%~XM@rVTJbZL09?ht@r^Z_6x}}atLjvH^4Vk#Ibf(^LiBJFqorm?A=lE zzFmwvp4bT@Nv2V>YQT92X;t9<2s|Ru5#w?wCvlhcHLcsq0TaFLKy(?nzezJ>CECqj zggrI~Hd4LudM(m{L@ezfnpELsRFVFw>fx;CqZtie`$BXRn#Ns%AdoE$-Pf~{9A8rV zf7FbgpKmVzmvn-z(g+&+-ID=v`;6=)itq8oM*+Uz**SMm_{%eP_c0{<%1JGiZS19o z@Gj7$Se~0lsu}w!%;L%~mIAO;AY-2i`9A*ZfFs=X!LTd6nWOZ7BZH2M{l2*I>Xu)0 z`<=;ObglnXcVk!T>e$H?El}ra0WmPZ$YAN0#$?|1v26^(quQre8;k20*dpd4N{i=b zuN=y}_ew9SlE~R{2+Rh^7%PA1H5X(p8%0TpJ=cqa$65XL)$#ign-y!qij3;2>j}I; ziO@O|aYfn&up5F`YtjGw68rD3{OSGNYmBnl?zdwY$=RFsegTZ=kkzRQ`r7ZjQP!H( zp4>)&zf<*N!tI00xzm-ME_a{_I!TbDCr;8E;kCH4LlL-tqLxDuBn-+xgPk37S&S2^ z2QZumkIimwz!c@!r0)j3*(jPIs*V!iLTRl0Cpt_UVNUgGZzdvs0(-yUghJfKr7;=h zD~y?OJ-bWJg;VdZ^r@vlDoeGV&8^--!t1AsIMZ5S440HCVr%uk- z2wV>!W1WCvFB~p$P$$_}|H5>uBeAe>`N1FI8AxM|pq%oNs;ED8x+tb44E) zTj{^fbh@eLi%5AqT?;d>Es5D*Fi{Bpk)q$^iF!!U`r2hHAO_?#!aYmf>G+jHsES4W zgpTKY59d?hsb~F0WE&dUp6lPt;Pm zcbTUqRryw^%{ViNW%Z(o8}dd00H(H-MmQmOiTq{}_rnwOr*Ybo7*}3W-qBT!#s0Ie z-s<1rvvJx_W;ViUD`04%1pra*Yw0BcGe)fDKUK8aF#BwBwMPU;9`!6E(~!043?SZx z13K%z@$$#2%2ovVlgFIPp7Q6(vO)ud)=*%ZSucL2Dh~K4B|%q4KnSpj#n@(0B})!9 z8p*hY@5)NDn^&Pmo;|!>erSYg`LkO?0FB@PLqRvc>4IsUM5O&>rRv|IBRxi(RX(gJ ztQ2;??L~&Mv;aVr5Q@(?y^DGo%pO^~zijld41aA0KKsy_6FeHIn?fNHP-z>$OoWer zjZ5hFQTy*-f7KENRiCE$ZOp4|+Wah|2=n@|W=o}bFM}Y@0e62+_|#fND5cwa3;P{^pEzlJbF1Yq^}>=wy8^^^$I2M_MH(4Dw{F6hm+vrWV5!q;oX z;tTNhz5`-V={ew|bD$?qcF^WPR{L(E%~XG8eJx(DoGzt2G{l8r!QPJ>kpHeOvCv#w zr=SSwMDaUX^*~v%6K%O~i)<^6`{go>a3IdfZ8hFmz&;Y@P%ZygShQZ2DSHd`m5AR= zx$wWU06;GYwXOf(%MFyj{8rPFXD};JCe85Bdp4$YJ2$TzZ7Gr#+SwCvBI1o$QP0(c zy`P51FEBV2HTisM3bHqpmECT@H!Y2-bv2*SoSPoO?wLe{M#zDTy@ujAZ!Izzky~3k zRA1RQIIoC*Mej1PH!sUgtkR0VCNMX(_!b65mo66iM*KQ7xT8t2eev$v#&YdUXKwGm z7okYAqYF&bveHeu6M5p9xheRCTiU8PFeb1_Rht0VVSbm%|1cOVobc8mvqcw!RjrMRM#~=7xibH&Fa5Imc|lZ{eC|R__)OrFg4@X_ ze+kk*_sDNG5^ELmHnZ7Ue?)#6!O)#Nv*Dl2mr#2)w{#i-;}0*_h4A%HidnmclH#;Q zmQbq+P4DS%3}PpPm7K_K3d2s#k~x+PlTul7+kIKol0@`YN1NG=+&PYTS->AdzPv!> zQvzT=)9se*Jr1Yq+C{wbK82gAX`NkbXFZ)4==j4t51{|-v!!$H8@WKA={d>CWRW+g z*`L>9rRucS`vbXu0rzA1#AQ(W?6)}1+oJSF=80Kf_2r~Qm-EJ6bbB3k`80rCv(0d` zvCf3;L2ovYG_TES%6vSuoKfIHC6w;V31!oqHM8-I8AFzcd^+_86!EcCOX|Ta9k1!s z_Vh(EGIIsI3fb&dF$9V8v(sTBC%!#<&KIGF;R+;MyC0~}$gC}}= zR`DbUVc&Bx`lYykFZ4{R{xRaUQkWCGCQlEc;!mf=+nOk$RUg*7 z;kP7CVLEc$CA7@6VFpsp3_t~m)W0aPxjsA3e5U%SfY{tp5BV5jH-5n?YX7*+U+Zs%LGR>U- z!x4Y_|4{gx?ZPJobISy991O znrmrC3otC;#4^&Rg_iK}XH(XX+eUHN0@Oe06hJk}F?`$)KmH^eWz@@N%wEc)%>?Ft z#9QAroDeyfztQ5Qe{m*#R#T%-h*&XvSEn@N$hYRTCMXS|EPwzF3IIysD2waj`vQD{ zv_#^Pgr?s~I*NE=acf@dWVRNWTr(GN0wrL)Z2=`Dr>}&ZDNX|+^Anl{Di%v1Id$_p zK5_H5`RDjJx`BW7hc85|> zHMMsWJ4KTMRHGu+vy*kBEMjz*^K8VtU=bXJYdhdZ-?jTXa$&n)C?QQIZ7ln$qbGlr zS*TYE+ppOrI@AoPP=VI-OXm}FzgXRL)OPvR$a_=SsC<3Jb+>5makX|U!}3lx4tX&L z^C<{9TggZNoeX!P1jX_K5HkEVnQ#s2&c#umzV6s2U-Q;({l+j^?hi7JnQ7&&*oOy9 z(|0asVTWUCiCnjcOnB2pN0DpuTglKq;&SFOQ3pUdye*eT<2()7WKbXp1qq9=bhMWlF-7BHT|i3TEIT77AcjD(v=I207wi-=vyiw5mxgPdTVUC z&h^FEUrXwWs9en2C{ywZp;nvS(Mb$8sBEh-*_d-OEm%~p1b2EpcwUdf<~zmJmaSTO zSX&&GGCEz-M^)G$fBvLC2q@wM$;n4jp+mt0MJFLuJ%c`tSp8$xuP|G81GEd2ci$|M z4XmH{5$j?rqDWoL4vs!}W&!?!rtj=6WKJcE>)?NVske(p;|#>vL|M_$as=mi-n-()a*OU3Okmk0wC<9y7t^D(er-&jEEak2!NnDiOQ99Wx8{S8}=Ng!e0tzj*#T)+%7;aM$ z&H}|o|J1p{IK0Q7JggAwipvHvko6>Epmh4RFRUr}$*2K4dz85o7|3#Bec9SQ4Y*;> zXWjT~f+d)dp_J`sV*!w>B%)#GI_;USp7?0810&3S=WntGZ)+tzhZ+!|=XlQ&@G@~3 z-dw@I1>9n1{+!x^Hz|xC+P#Ab`E@=vY?3%Bc!Po~e&&&)Qp85!I|U<-fCXy*wMa&t zgDk!l;gk;$taOCV$&60z+}_$ykz=Ea*)wJQ3-M|p*EK(cvtIre0Pta~(95J7zoxBN zS(yE^3?>88AL0Wfuou$BM{lR1hkrRibz=+I9ccwd`ZC*{NNqL)3pCcw^ygMmrG^Yp zn5f}Xf>%gncC=Yq96;rnfp4FQL#{!Y*->e82rHgY4Zwy{`JH}b9*qr^VA{%~Z}jtp z_t$PlS6}5{NtTqXHN?uI8ut8rOaD#F1C^ls73S=b_yI#iZDOGz3#^L@YheGd>L;<( z)U=iYj;`{>VDNzIxcjbTk-X3keXR8Xbc`A$o5# zKGSk-7YcoBYuAFFSCjGi;7b<;n-*`USs)IX z=0q6WZ=L!)PkYtZE-6)azhXV|+?IVGTOmMCHjhkBjfy@k1>?yFO3u!)@cl{fFAXnRYsWk)kpT?X{_$J=|?g@Q}+kFw|%n!;Zo}|HE@j=SFMvT8v`6Y zNO;tXN^036nOB2%=KzxB?n~NQ1K8IO*UE{;Xy;N^ZNI#P+hRZOaHATz9(=)w=QwV# z`z3+P>9b?l-@$@P3<;w@O1BdKh+H;jo#_%rr!ute{|YX4g5}n?O7Mq^01S5;+lABE+7`&_?mR_z7k|Ja#8h{!~j)| zbBX;*fsbUak_!kXU%HfJ2J+G7;inu#uRjMb|8a){=^))y236LDZ$$q3LRlat1D)%7K0!q5hT5V1j3qHc7MG9 z_)Q=yQ>rs>3%l=vu$#VVd$&IgO}Za#?aN!xY>-<3PhzS&q!N<=1Q7VJBfHjug^4|) z*fW^;%3}P7X#W3d;tUs3;`O&>;NKZBMR8au6>7?QriJ@gBaorz-+`pUWOP73DJL=M z(33uT6Gz@Sv40F6bN|H=lpcO z^AJl}&=TIjdevuDQ!w0K*6oZ2JBOhb31q!XDArFyKpz!I$p4|;c}@^bX{>AXdt7Bm zaLTk?c%h@%xq02reu~;t@$bv`b3i(P=g}~ywgSFpM;}b$zAD+=I!7`V~}ARB(Wx0C(EAq@?GuxOL9X+ffbkn3+Op0*80TqmpAq~EXmv%cq36celXmRz z%0(!oMp&2?`W)ALA&#|fu)MFp{V~~zIIixOxY^YtO5^FSox8v$#d0*{qk0Z)pNTt0QVZ^$`4vImEB>;Lo2!7K05TpY-sl#sWBz_W-aDIV`Ksabi zvpa#93Svo!70W*Ydh)Qzm{0?CU`y;T^ITg-J9nfWeZ-sbw)G@W?$Eomf%Bg2frfh5 zRm1{|E0+(4zXy){$}uC3%Y-mSA2-^I>Tw|gQx|7TDli_hB>``)Q^aZ`LJC2V3U$SABP}T)%}9g2pF9dT}aC~!rFFgkl1J$ z`^z{Arn3On-m%}r}TGF8KQe*OjSJ=T|caa_E;v89A{t@$yT^(G9=N9F?^kT*#s3qhJq!IH5|AhnqFd z0B&^gm3w;YbMNUKU>naBAO@fbz zqw=n!@--}o5;k6DvTW9pw)IJVz;X}ncbPVrmH>4x);8cx;q3UyiML1PWp%bxSiS|^ zC5!kc4qw%NSOGQ*Kcd#&$30=lDvs#*4W4q0u8E02U)7d=!W7+NouEyuF1dyH$D@G& zaFaxo9Ex|ZXA5y{eZT*i*dP~INSMAi@mvEX@q5i<&o&#sM}Df?Og8n8Ku4vOux=T% zeuw~z1hR}ZNwTn8KsQHKLwe2>p^K`YWUJEdVEl|mO21Bov!D0D$qPoOv=vJJ`)|%_ z>l%`eexY7t{BlVKP!`a^U@nM?#9OC*t76My_E_<16vCz1x_#82qj2PkWiMWgF8bM9 z(1t4VdHcJ;B~;Q%x01k_gQ0>u2*OjuEWNOGX#4}+N?Gb5;+NQMqp}Puqw2HnkYuKA zzKFWGHc&K>gwVgI1Sc9OT1s6fq=>$gZU!!xsilA$fF`kLdGoX*^t}ao@+^WBpk>`8 z4v_~gK|c2rCq#DZ+H)$3v~Hoi=)=1D==e3P zpKrRQ+>O^cyTuWJ%2}__0Z9SM_z9rptd*;-9uC1tDw4+A!=+K%8~M&+Zk#13hY$Y$ zo-8$*8dD5@}XDi19RjK6T^J~DIXbF5w&l?JLHMrf0 zLv0{7*G!==o|B%$V!a=EtVHdMwXLtmO~vl}P6;S(R2Q>*kTJK~!}gloxj)m|_LYK{ zl(f1cB=EON&wVFwK?MGn^nWuh@f95SHatPs(jcwSY#Dnl1@_gkOJ5=f`%s$ZHljRH0 z+c%lrb=Gi&N&1>^L_}#m>=U=(oT^vTA&3!xXNyqi$pdW1BDJ#^{h|2tZc{t^vag3& zAD7*8C`chNF|27itjBUo^CCDyEpJLX3&u+(L;YeeMwnXEoyN(ytoEabcl$lSgx~Ltatn}b$@j_yyMrBb03)shJE*$;Mw=;mZd&8e>IzE+4WIoH zCSZE7WthNUL$|Y#m!Hn?x7V1CK}V`KwW2D$-7&ODy5Cj;!_tTOOo1Mm%(RUt)#$@3 zhurA)t<7qik%%1Et+N1?R#hdBB#LdQ7{%-C zn$(`5e0eFh(#c*hvF>WT*07fk$N_631?W>kfjySN8^XC9diiOd#s?4tybICF;wBjp zIPzilX3{j%4u7blhq)tnaOBZ_`h_JqHXuI7SuIlNTgBk9{HIS&3|SEPfrvcE<@}E` zKk$y*nzsqZ{J{uWW9;#n=de&&h>m#A#q)#zRonr(?mDOYU&h&aQWD;?Z(22wY?t$U3qo`?{+amA$^TkxL+Ex2dh`q7iR&TPd0Ymwzo#b? zP$#t=elB5?k$#uE$K>C$YZbYUX_JgnXA`oF_Ifz4H7LEOW~{Gww&3s=wH4+j8*TU| zSX%LtJWqhr-xGNSe{;(16kxnak6RnZ{0qZ^kJI5X*It_YuynSpi(^-}Lolr{)#z_~ zw!(J-8%7Ybo^c3(mED`Xz8xecP35a6M8HarxRn%+NJBE;dw>>Y2T&;jzRd4FSDO3T zt*y+zXCtZQ0bP0yf6HRpD|WmzP;DR^-g^}{z~0x~z4j8m zucTe%k&S9Nt-?Jb^gYW1w6!Y3AUZ0Jcq;pJ)Exz%7k+mUOm6%ApjjSmflfKwBo6`B zhNb@$NHTJ>guaj9S{@DX)!6)b-Shav=DNKWy(V00k(D!v?PAR0f0vDNq*#mYmUp6> z76KxbFDw5U{{qx{BRj(>?|C`82ICKbfLxoldov-M?4Xl+3;I4GzLHyPOzYw7{WQST zPNYcx5onA%MAO9??41Po*1zW(Y%Zzn06-lUp{s<3!_9vv9HBjT02On0Hf$}NP;wF) zP<`2p3}A^~1YbvOh{ePMx$!JGUPX-tbBzp3mDZMY;}h;sQ->!p97GA)9a|tF(Gh{1$xk7 zUw?ELkT({Xw!KIr);kTRb1b|UL`r2_`a+&UFVCdJ)1T#fdh;71EQl9790Br0m_`$x z9|ZANuchFci8GNZ{XbP=+uXSJRe(;V5laQz$u18#?X*9}x7cIEbnr%<=1cX3EIu7$ zhHW6pe5M(&qEtsqRa>?)*{O;OJT+YUhG5{km|YI7I@JL_3Hwao9aXneiSA~a* z|Lp@c-oMNyeAEuUz{F?kuou3x#C*gU?lon!RC1s37gW^0Frc`lqQWH&(J4NoZg3m8 z;Lin#8Q+cFPD7MCzj}#|ws7b@?D9Q4dVjS4dpco=4yX5SSH=A@U@yqPdp@?g?qeia zH=Tt_9)G=6C2QIPsi-QipnK(mc0xXIN;j$WLf@n8eYvMk;*H-Q4tK%(3$CN}NGgO8n}fD~+>?<3UzvsrMf*J~%i;VKQHbF%TPalFi=#sgj)(P#SM^0Q=Tr>4kJVw8X3iWsP|e8tj}NjlMdWp z@2+M4HQu~3!=bZpjh;;DIDk&X}=c8~kn)FWWH z2KL1w^rA5&1@@^X%MjZ7;u(kH=YhH2pJPFQe=hn>tZd5RC5cfGYis8s9PKaxi*}-s6*W zRA^PwR=y^5Z){!(4D9-KC;0~;b*ploznFOaU`bJ_7U?qAi#mTo!&rIECRL$_y@yI27x2?W+zqDBD5~KCVYKFZLK+>ABC(Kj zeAll)KMgIlAG`r^rS{loBrGLtzhHY8$)<_S<(Dpkr(Ym@@vnQ&rS@FC*>2@XCH}M+an74WcRDcoQ+a3@A z9tYhl5$z7bMdTvD2r&jztBuo37?*k~wcU9GK2-)MTFS-lux-mIRYUuGUCI~V$?s#< z?1qAWb(?ZLm(N>%S%y10COdaq_Tm5c^%ooIxpR=`3e4C|@O5wY+eLik&XVi5oT7oe zmxH)Jd*5eo@!7t`x8!K=-+zJ-Sz)B_V$)s1pW~CDU$=q^&ABvf6S|?TOMB-RIm@CoFg>mjIQE)?+A1_3s6zmFU_oW&BqyMz1mY*IcP_2knjq5 zqw~JK(cVsmzc7*EvTT2rvpeqhg)W=%TOZ^>f`rD4|7Z5fq*2D^lpCttIg#ictgqZ$P@ru6P#f$x#KfnfTZj~LG6U_d-kE~`;kU_X)`H5so@?C zWmb!7x|xk@0L~0JFall*@ltyiL^)@3m4MqC7(7H0sH!WidId1#f#6R{Q&A!XzO1IAcIx;$k66dumt6lpUw@nL2MvqJ5^kbOVZ<^2jt5-njy|2@`07}0w z;M%I1$FCoLy`8xp8Tk)bFr;7aJeQ9KK6p=O$U0-&JYYy8woV*>b+FB?xLX`=pirYM z5K$BA(u)+jR{?O2r$c_Qvl?M{=Ar{yQ!UVsVn4k@0!b?_lA;dVz9uaQUgBH8Oz(Sb zrEs;&Ey>_ex8&!N{PmQjp+-Hlh|OA&wvDai#GpU=^-B70V0*LF=^bi+Nhe_o|azZ%~ZZ1$}LTmWt4aoB1 zPgccm$EwYU+jrdBaQFxQfn5gd(gM`Y*Ro1n&Zi?j=(>T3kmf94vdhf?AuS8>$Va#P zGL5F+VHpxdsCUa}+RqavXCobI-@B;WJbMphpK2%6t=XvKWWE|ruvREgM+|V=i6;;O zx$g=7^`$XWn0fu!gF=Xe9cMB8Z_SelD>&o&{1XFS`|nInK3BXlaeD*rc;R-#osyIS zWv&>~^TLIyBB6oDX+#>3<_0+2C4u2zK^wmHXXDD9_)kmLYJ!0SzM|%G9{pi)`X$uf zW}|%%#LgyK7m(4{V&?x_0KEDq56tk|0YNY~B(Sr|>WVz-pO3A##}$JCT}5P7DY+@W z#gJv>pA5>$|E3WO2tV7G^SuymB?tY`ooKcN3!vaQMnBNk-WATF{-$#}FyzgtJ8M^; zUK6KWSG)}6**+rZ&?o@PK3??uN{Q)#+bDP9i1W&j)oaU5d0bIWJ_9T5ac!qc?x66Q z$KUSZ`nYY94qfN_dpTFr8OW~A?}LD;Yty-BA)-be5Z3S#t2Io%q+cAbnGj1t$|qFR z9o?8B7OA^KjCYL=-!p}w(dkC^G6Nd%_I=1))PC0w5}ZZGJxfK)jP4Fwa@b-SYBw?% zdz9B-<`*B2dOn(N;mcTm%Do)rIvfXRNFX&1h`?>Rzuj~Wx)$p13nrDlS8-jwq@e@n zNIj_|8or==8~1h*Ih?w*8K7rYkGlwlTWAwLKc5}~dfz3y`kM&^Q|@C%1VAp_$wnw6zG~W4O+^ z>i?NY?oXf^Puc~+fDM$VgRNBpOZj{2cMP~gCqWAX4 z7>%$ux8@a&_B(pt``KSt;r+sR-$N;jdpY>|pyvPiN)9ohd*>mVST3wMo)){`B(&eX z1?zZJ-4u9NZ|~j1rdZYq4R$?swf}<6(#ex%7r{kh%U@kT)&kWuAszS%oJts=*OcL9 zaZwK<5DZw%1IFHXgFplP6JiL^dk8+SgM$D?8X+gE4172hXh!WeqIO>}$I9?Nry$*S zQ#f)RuH{P7RwA3v9f<-w>{PSzom;>(i&^l{E0(&Xp4A-*q-@{W1oE3K;1zb{&n28dSC2$N+6auXe0}e4b z)KLJ?5c*>@9K#I^)W;uU_Z`enquTUxr>mNq z1{0_puF-M7j${rs!dxxo3EelGodF1TvjV;Zpo;s{5f1pyCuRp=HDZ?s#IA4f?h|-p zGd|Mq^4hDa@Bh!c4ZE?O&x&XZ_ptZGYK4$9F4~{%R!}G1leCBx`dtNUS|K zL-7J5s4W@%mhXg1!}a4PD%!t&Qn%f_oquRajn3@C*)`o&K9o7V6DwzVMEhjVdDJ1fjhr#@=lp#@4EBqi=CCQ>73>R(>QKPNM&_Jpe5G`n4wegeC`FYEPJ{|vwS>$-`fuRSp3927qOv|NC3T3G-0 zA{K`|+tQy1yqE$ShWt8ny&5~)%ITb@^+x$w0)f&om;P8B)@}=Wzy59BwUfZ1vqw87 za2lB8J(&*l#(V}Id8SyQ0C(2amzkz3EqG&Ed0Jq1)$|&>4_|NIe=5|n=3?siFV0fI z{As5DLW^gs|B-b4C;Hd(SM-S~GQhzb>HgF2|2Usww0nL^;x@1eaB)=+Clj+$fF@H( z-fqP??~QMT$KI-#m;QC*&6vkp&8699G3)Bq0*kFZXINw=b9OVaed(3(3kS|IZ)CM? zJdnW&%t8MveBuK21uiYj)_a{Fnw0OErMzMN?d$QoPwkhOwcP&p+t>P)4tHlYw-pPN z^oJ=uc$Sl>pv@fZH~ZqxSvdhF@F1s=oZawpr^-#l{IIOGG=T%QXjtwPhIg-F@k@uIlr?J->Ia zpEUQ*=4g|XYn4Gez&aHr*;t$u3oODPmc2Ku)2Og|xjc%w;q!Zz+zY)*3{7V8bK4;& zYV82FZ+8?v)`J|G1w4I0fWdKg|2b#iaazCv;|?(W-q}$o&Y}Q5d@BRk^jL7#{kbCK zSgkyu;=DV+or2)AxCBgq-nj5=@n^`%T#V+xBGEkW4lCqrE)LMv#f;AvD__cQ@Eg3`~x| zW+h9mofSXCq5|M)9|ez(#X?-sxB%Go8};sJ?2abp(Y!lyi>k)|{M*Z$c{e1-K4ky` MPgg&ebxsLQ025IeI{*Lx diff --git a/my_tests_2/web/index.html b/my_tests_2/web/index.html deleted file mode 100644 index 886f7ac1215..00000000000 --- a/my_tests_2/web/index.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - my_tests_2 - - - - - - diff --git a/my_tests_2/web/manifest.json b/my_tests_2/web/manifest.json deleted file mode 100644 index 4843613780b..00000000000 --- a/my_tests_2/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "my_tests_2", - "short_name": "my_tests_2", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} From 01e81cd06cff509a4ec7b1a0631cfd632b9d6bb3 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 15 Nov 2025 23:37:47 +0100 Subject: [PATCH 04/87] Delete my_tests directory --- my_tests/lib/main.dart | 53 ------------------------------------------ 1 file changed, 53 deletions(-) delete mode 100644 my_tests/lib/main.dart diff --git a/my_tests/lib/main.dart b/my_tests/lib/main.dart deleted file mode 100644 index 89c9b2cfbea..00000000000 --- a/my_tests/lib/main.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/game.dart'; -import 'package:flutter/material.dart'; - -void main() { - runApp(GameWidget(game: ZoomTestGame())); -} - -class ZoomTestGame extends FlameGame with ScaleDetector { - late RectComponent rect; - Vector2 zoomCenter = Vector2.zero(); - - @override - Future onLoad() async { - rect = RectComponent( - position: size / 2, - size: Vector2.all(100), - anchor: Anchor.center, - paint: Paint()..color = Colors.blue, - ); - add(rect); - } - - @override - void onScaleStart(ScaleStartInfo info) { - zoomCenter = info.eventPosition.game; - } - - @override - void onScaleUpdate(ScaleUpdateInfo info) { - final zoomFactor = info.scale.global; - final before = zoomCenter - camera.position; - camera.zoom = (camera.zoom * zoomFactor).clamp(0.2, 5.0); - final after = zoomCenter - camera.position; - camera.position += before - after; - } -} - -class RectComponent extends PositionComponent { - final Paint paint; - RectComponent({ - required super.position, - required super.size, - required this.paint, - super.anchor, - }); - - @override - void render(Canvas canvas) { - canvas.drawRect(size.toRect(), paint); - } -} \ No newline at end of file From 8701f0066e2194b33e51ccb6eebcce73487faef7 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 16 Nov 2025 17:02:42 +0100 Subject: [PATCH 05/87] fix typo and format example --- examples/lib/stories/input/scale_example.dart | 60 +++++++++---------- .../flame_game_mixins/scale_dispatcher.dart | 2 +- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_example.dart index 24f8967cff6..d531c5802d1 100644 --- a/examples/lib/stories/input/scale_example.dart +++ b/examples/lib/stories/input/scale_example.dart @@ -4,7 +4,6 @@ import 'package:flame/events.dart'; import 'package:flame/game.dart'; import 'package:flutter/material.dart'; - void main() { runApp(GameWidget(game: ScaleExample())); } @@ -35,28 +34,28 @@ class ScaleExample extends FlameGame { position: Vector2(50, 50), ); - if(addScaleOnlyRectangle){ + if (addScaleOnlyRectangle) { final interect2 = ScaleOnlyRectangle( - position: Vector2(0, 0), - size: Vector2.all(150), - ); + position: Vector2(0, 0), + size: Vector2.all(150), + ); world.add(interect2); } - if(addDragOnlyRectangle){ + if (addDragOnlyRectangle) { final interect3 = DragOnlyRectangle( - position: Vector2(-200, -200), - size: Vector2.all(150), - color: Colors.green, - ); + position: Vector2(-200, -200), + size: Vector2.all(150), + color: Colors.green, + ); world.add(interect3); } - if(addScaleDragRectangle){ + if (addScaleDragRectangle) { interect = InteractiveRectangle( - position: Vector2(200, 200), - size: Vector2.all(150), - color: Colors.red, - ); + position: Vector2(200, 200), + size: Vector2.all(150), + color: Colors.red, + ); world.add(interect); } @@ -67,10 +66,10 @@ class ScaleExample extends FlameGame { void update(double dt) { super.update(dt); - if(addCameraRotation){ + if (addCameraRotation) { camera.viewfinder.angle += 0.001; } - if(addZoom){ + if (addZoom) { debugText.text = '${camera.viewfinder.zoom}'; camera.viewfinder.zoom += 0.001; } @@ -99,13 +98,13 @@ class InteractiveRectangle extends RectangleComponent @override Future onLoad() async { - final text = TextComponent( + final text = TextComponent( text: 'drag + scale', textRenderer: TextPaint( style: const TextStyle(fontSize: 25, color: Colors.white), ), - position: size/2, - anchor: Anchor.center + position: size / 2, + anchor: Anchor.center, ); add(text); } @@ -120,7 +119,7 @@ class InteractiveRectangle extends RectangleComponent @override void onDragUpdate(DragUpdateEvent event) { super.onDragUpdate(event); - if (isScaling){ + if (isScaling) { return; } final rotated = event.canvasDelta.clone() @@ -174,8 +173,8 @@ class InteractiveRectangle extends RectangleComponent } /// A rectangle that only responds to drag -class DragOnlyRectangle extends RectangleComponent - with DragCallbacks, HasGameReference { +class DragOnlyRectangle extends RectangleComponent + with DragCallbacks, HasGameReference { DragOnlyRectangle({ required Vector2 position, required Vector2 size, @@ -188,15 +187,15 @@ class DragOnlyRectangle extends RectangleComponent paint: Paint()..color = color, ); - @override + @override Future onLoad() async { - final text = TextComponent( + final text = TextComponent( text: 'drag', textRenderer: TextPaint( style: const TextStyle(fontSize: 25, color: Colors.white), ), - position: size/2, - anchor: Anchor.center + position: size / 2, + anchor: Anchor.center, ); add(text); } @@ -240,13 +239,13 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { @override Future onLoad() async { - final text = TextComponent( + final text = TextComponent( text: 'scale', textRenderer: TextPaint( style: const TextStyle(fontSize: 25, color: Colors.white), ), - position: size/2, - anchor: Anchor.center + position: size / 2, + anchor: Anchor.center, ); add(text); } @@ -293,4 +292,3 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { debugPrint('Scale ended with velocity ${event.velocity}'); } } - 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 2c449830a21..ff1c92997fa 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 @@ -31,7 +31,7 @@ class _LineBetweenPointers { final Offset pointerEndLocation; } -/// Unique key for the [MultiScaleDispatcher] so the game can identify it. +/// Unique key for the [ScaleDispatcher] so the game can identify it. class ScaleDispatcherKey implements ComponentKey { const ScaleDispatcherKey(); From 8b7105abf77f25917aa5150220435263ae14670e Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 16 Nov 2025 17:10:52 +0100 Subject: [PATCH 06/87] fix spelling --- examples/lib/stories/input/scale_example.dart | 14 +++++++------- .../events/flame_game_mixins/scale_dispatcher.dart | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_example.dart index d531c5802d1..e6bee5312f9 100644 --- a/examples/lib/stories/input/scale_example.dart +++ b/examples/lib/stories/input/scale_example.dart @@ -12,7 +12,7 @@ class ScaleExample extends FlameGame { late RectangleComponent rect; late TextComponent debugText; - late InteractiveRectangle interect; + late InteractiveRectangle interactiveRectangle; Vector2 zoomCenter = Vector2.zero(); double startingZoom = 1; @@ -35,28 +35,28 @@ class ScaleExample extends FlameGame { ); if (addScaleOnlyRectangle) { - final interect2 = ScaleOnlyRectangle( + final scaleOnlyRectangle = ScaleOnlyRectangle( position: Vector2(0, 0), size: Vector2.all(150), ); - world.add(interect2); + world.add(scaleOnlyRectangle); } if (addDragOnlyRectangle) { - final interect3 = DragOnlyRectangle( + final dragOnlyRectangle = DragOnlyRectangle( position: Vector2(-200, -200), size: Vector2.all(150), color: Colors.green, ); - world.add(interect3); + world.add(dragOnlyRectangle); } if (addScaleDragRectangle) { - interect = InteractiveRectangle( + interactiveRectangle = InteractiveRectangle( position: Vector2(200, 200), size: Vector2.all(150), color: Colors.red, ); - world.add(interect); + world.add(interactiveRectangle); } camera.viewport.add(debugText); 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 ff1c92997fa..137f378ce59 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 @@ -94,7 +94,7 @@ class ScaleDispatcher extends Component implements ScaleListener { // Also deliver to components that started the scale but weren't under // the pointer this frame - // TODO(stilnat): Currently, the id passed to the scale + // TODO: Currently, the id passed to the scale // events is always 0, so maybe not relevant. for (final record in _records) { if (record.pointerId == event.pointerId && !updated.contains(record)) { @@ -160,7 +160,7 @@ class ScaleDispatcher extends Component implements ScaleListener { // and rotation data. _lineAtFirstUpdate ??= _currentLine; - // TODO(stilnat): Do we also need to recompute local focal point, + // TODO: Do we also need to recompute local focal point, // local relative to what ? return ScaleUpdateDetails( focalPoint: _computeFocalPoint(details), @@ -215,7 +215,7 @@ class ScaleDispatcher extends Component implements ScaleListener { /// 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 lenght of current line over lenght of initial line. + /// is just length of current line over length of initial line. double _computeScale(ScaleUpdateDetails details) { if (lastDragUpdate == null || _currentLine == null || @@ -237,8 +237,8 @@ class ScaleDispatcher extends Component implements ScaleListener { /// 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 lenght of current line vertical part over - /// lenght of initial line part. + /// is just length of current line vertical part over + /// length of initial line part. double _computeVerticalScale(ScaleUpdateDetails details) { if (lastDragUpdate == null || _currentLine == null || @@ -261,8 +261,8 @@ class ScaleDispatcher extends Component implements ScaleListener { /// 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 lenght of current line horizontal part over - /// lenght of initial line part. + /// is just length of current line horizontal part over + /// length of initial line part. double _computeHorizontalScale(ScaleUpdateDetails details) { if (lastDragUpdate == null || _currentLine == null || From 9723f97d2dcec66b682d3270a05ecbefac691a38 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 16 Nov 2025 17:20:37 +0100 Subject: [PATCH 07/87] remove TODO --- .../lib/src/events/flame_game_mixins/scale_dispatcher.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 137f378ce59..b4a41a486a2 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 @@ -94,8 +94,8 @@ class ScaleDispatcher extends Component implements ScaleListener { // Also deliver to components that started the scale but weren't under // the pointer this frame - // TODO: Currently, the id passed to the scale - // events is always 0, so maybe not relevant. + // Currently, the id passed to the scale + // events is always 0, so maybe it's not relevant. for (final record in _records) { if (record.pointerId == event.pointerId && !updated.contains(record)) { record.component.onScaleUpdate(event); @@ -160,7 +160,8 @@ class ScaleDispatcher extends Component implements ScaleListener { // and rotation data. _lineAtFirstUpdate ??= _currentLine; - // TODO: Do we also need to recompute local focal point, + + // Do we also need to recompute local focal point, // local relative to what ? return ScaleUpdateDetails( focalPoint: _computeFocalPoint(details), From 28e24bf109999d881ec5cf3479b40a01204f8633 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 16 Nov 2025 17:27:31 +0100 Subject: [PATCH 08/87] format scale dispatcher --- .../flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart | 1 - 1 file changed, 1 deletion(-) 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 b4a41a486a2..7a36637e12e 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 @@ -160,7 +160,6 @@ class ScaleDispatcher extends Component implements ScaleListener { // and rotation data. _lineAtFirstUpdate ??= _currentLine; - // Do we also need to recompute local focal point, // local relative to what ? return ScaleUpdateDetails( From 9c2acf62554fb459e7daf634632730afa9622943 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 16 Nov 2025 18:16:03 +0100 Subject: [PATCH 09/87] remove automatically create multi drag dispatcher in Scale dispatcher --- .../flame_game_mixins/scale_dispatcher.dart | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) 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 7a36637e12e..9c920963ca4 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 @@ -62,6 +62,8 @@ class ScaleDispatcher extends Component implements ScaleListener { _LineBetweenPointers? _lineAtFirstUpdate; + MultiDragDispatcher? _multiDragDispatcher; + /// Called when the user starts a scale gesture. @mustCallSuper void onScaleStart(ScaleStartEvent event) { @@ -303,22 +305,34 @@ class ScaleDispatcher extends Component implements ScaleListener { ..onEnd = handleScaleEnd; }, ); + final existingDispatcher = game.findByKey(const MultiDragDispatcherKey()); if (existingDispatcher != null) { - listenToDragDispatcher(existingDispatcher as MultiDragDispatcher); - } else { - // If the MultiDragDispatcher wasn't already added we add it - // ourselves here. - final game = findRootGame()!; - final dispatcher = MultiDragDispatcher(); - game.registerKey(const MultiDragDispatcherKey(), dispatcher); - game.add(dispatcher); - listenToDragDispatcher(dispatcher); + _attachMultiDragDispatcher(existingDispatcher as MultiDragDispatcher); } super.onMount(); } + @override + void onChildrenChanged(Component child, ChildrenChangeType type) { + super.onChildrenChanged(child, type); + + // 2. Si un nouvel enfant est ajouté au parent + 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(); From 975bbdbc945ddb4ed0673747a2d6f12cd943d44c Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 16 Nov 2025 18:19:34 +0100 Subject: [PATCH 10/87] remove french --- .../flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart | 1 - 1 file changed, 1 deletion(-) 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 9c920963ca4..30f6d5384e5 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 @@ -318,7 +318,6 @@ class ScaleDispatcher extends Component implements ScaleListener { void onChildrenChanged(Component child, ChildrenChangeType type) { super.onChildrenChanged(child, type); - // 2. Si un nouvel enfant est ajouté au parent if (type == ChildrenChangeType.added && child is MultiDragDispatcher) { _attachMultiDragDispatcher(child); } From bea44fc36494681afa61e11734798387d1b270ff Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 16 Nov 2025 18:27:23 +0100 Subject: [PATCH 11/87] format scale dispatcher --- .../lib/src/events/flame_game_mixins/scale_dispatcher.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 30f6d5384e5..90d7788ed13 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 @@ -324,9 +324,9 @@ class ScaleDispatcher extends Component implements ScaleListener { } void _attachMultiDragDispatcher(MultiDragDispatcher newDispatcher) { - if (_multiDragDispatcher != null){ + if (_multiDragDispatcher != null) { return; - } + } _multiDragDispatcher = newDispatcher; listenToDragDispatcher(newDispatcher); From d6f78cbf2f146dd2e8f38d9b7dc44d18c8b14d92 Mon Sep 17 00:00:00 2001 From: stilnat Date: Thu, 20 Nov 2025 00:05:57 +0100 Subject: [PATCH 12/87] test first batch --- .../component_mixins/scale_callbacks.dart | 17 +- .../scale_callbacks_test.dart | 535 ++++++++++++++++++ .../lib/src/mock_tap_drag_events.dart | 48 ++ 3 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 packages/flame/test/events/component_mixins/scale_callbacks_test.dart 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 51108ddad4e..4d5dddef5ae 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -4,9 +4,22 @@ import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flutter/foundation.dart'; mixin ScaleCallbacks on Component { - void onScaleStart(ScaleStartEvent event) {} + bool _isScaled = false; + + /// Returns true while the component is being scaled. + bool get isScaled => _isScaled; + + @mustCallSuper + void onScaleStart(ScaleStartEvent event) { + _isScaled = true; + } + void onScaleUpdate(ScaleUpdateEvent event) {} - void onScaleEnd(ScaleEndEvent event) {} + + @mustCallSuper + void onScaleEnd(ScaleEndEvent event) { + _isScaled = false; + } @override @mustCallSuper diff --git a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart new file mode 100644 index 00000000000..8b702987824 --- /dev/null +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -0,0 +1,535 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart' hide PointerMoveEvent; +import 'package:flame/game.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/gestures.dart' show PointerAddedEvent, kPrimaryButton; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ScaleCallbacks', () { + testWithFlameGame( + 'make sure ScaleCallback components can be added to a FlameGame', + (game) async { + await game.add(_ScaleCallbacksComponent()); + await game.ready(); + expect(game.children.toList()[2], isA()); + }, + ); + }); + testWithFlameGame('scale event start', (game) async { + final component = _ScaleCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + game.add(component); + await game.ready(); + + expect(game.children.whereType().length, 1); + game.firstChild()!.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.containsLocalPoint(Vector2(10, 10)), false); + }); + + testWithFlameGame('scale event start, update and end', (game) async { + final component = _ScaleCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.scaleStartEvent, 1); + expect(component.scaleUpdateEvent, 0); + expect(component.scaleEndEvent, 0); + + dispatcher.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)); + + dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + expect(component.scaleEndEvent, equals(1)); + }); + + testWithFlameGame( + 'scale event update not called without onScaleStart', + (game) async { + final component = _ScaleCallbacksComponent() + ..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)); + }, + ); + +testWidgets('scale correctly registered handled event', (tester) async { + final component = _ScaleCallbacksComponent() + ..x = 100 + ..y = 100 + ..width = 150 + ..height = 150; + final game = FlameGame(children: [component]); + + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + + await performPinchGesture(tester, + center: Offset(150, 150), + startSeparation: Offset(30, 0), + moveDelta: Offset(15, 2) + ); + + expect(game.children.length, equals(4)); + expect(component.isMounted, isTrue); + + expect(component.scaleStartEvent, equals(2)); + expect(component.scaleUpdateEvent, greaterThan(0)); + expect(component.scaleEndEvent, equals(2)); +}); + +testWidgets( + 'scale outside of component is not registered as handled', + (tester) async { + 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 performPinchGesture(tester, + center: Offset(200, 200), + startSeparation: Offset(50, 0), + moveDelta: Offset(15, 2) + ); + + expect(component.scaleStartEvent, equals(0)); + expect(component.scaleUpdateEvent, equals(0)); + expect(component.scaleEndEvent, equals(0)); + }, +); + +testWithGame( + 'make sure the FlameGame can registers Scale Callbacks on itself', + _ScaleCallbacksGame.new, + (game) async { + await game.ready(); + expect(game.children.length, equals(3)); + expect(game.children.elementAt(1), isA()); + }, +); + +testWidgets( + 'scale correctly registered handled event directly on FlameGame', + (tester) async { + 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 performPinchGesture(tester, + center: Offset(100, 100), + startSeparation: Offset(50, 0), + moveDelta: Offset(15, 2) + ); + + expect(game.scaleStartEvent, equals(2)); + expect(game.scaleUpdateEvent, greaterThan(0)); + expect(game.scaleEndEvent, equals(2)); + }, +); + +testWidgets( + 'isDragged is changed', + (tester) async { + final component = _ScaleCallbacksComponent()..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 performPinchGesture(tester, + center: Offset(150, 150), + startSeparation: Offset(30, 0), + moveDelta: Offset(15, 2) + ); + + expect(component.isScaledStateChange, equals(4)); + + // Outside component + await performPinchGesture(tester, + center: Offset(300, 300), + startSeparation: Offset(30, 0), + moveDelta: Offset(15, 2) + ); + expect(component.isScaledStateChange, equals(4)); + }, + ); + group('HasDraggableComponents', () { + + testWidgets( + 'drag event does not affect more than one component', + (tester) async { + var nEvents = 0; + final game = FlameGame( + children: [ + _ScaleWithCallbacksComponent( + size: Vector2.all(100), + onScaleStart: (e) => nEvents++, + onScaleUpdate: (e) => nEvents++, + onScaleEnd: (e) => nEvents++, + ), + _SimpleScaleCallbacksComponent(size: Vector2.all(200))..priority = 10, + ], + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + await performPinchGesture(tester, + center: Offset(50, 50), + startSeparation: Offset(30, 0), + moveDelta: Offset(15, 2) + ); + + expect(nEvents, 0); + }, + ); + + testWidgets( + 'drag event can move outside the component bounds and still fire', + (tester) async { + final points = []; + final game = FlameGame( + children: [ + _ScaleWithCallbacksComponent( + size: Vector2.all(500), + position: Vector2.all(5), + onScaleUpdate: (e) => points.add(Vector2.all(e.scale)), + ), + ], + ); + // TODO isn't the issue actually localStartPosition ??? + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final center = Offset(150, 150); + await tester.timedZoomFrom( + center.translate(-10, 0), const Offset(-10, 0), + center.translate(10, 0), const Offset(10, 0), + const Duration(seconds: 1), + frequency: 10); + + /*await performScaleGestureTimed( + tester, + pinch: false, + center: Offset(150, 150), + duration: Duration(milliseconds: 300), + steps: 15, + startSeparation: Offset(100, 0), + moveDelta: Offset(45, 2), + );*/ + //expect(points.length, 80); + debugPrint('${points.length}'); + expect(points.first, Vector2(75, 75)); + expect( + points.skip(1), + List.generate(41, (i) => Vector2(75.0, 75.0 + i)), + ); + }, + ); + }); + +} + + +Future performScaleGestureTimed( + WidgetTester tester, { + required Offset center, + required bool pinch, + required Offset startSeparation, + required Offset moveDelta, + int steps = 10, + Duration duration = const Duration(milliseconds: 300), +}) async { + final binding = tester.binding; + final p1 = TestPointer(1, PointerDeviceKind.touch); + final p2 = TestPointer(2, PointerDeviceKind.touch); + + Offset pos1 = center - startSeparation; + Offset pos2 = center + startSeparation; + + // Pointer down + binding.handlePointerEvent(p1.down(pos1)); + binding.handlePointerEvent(p2.down(pos2)); + + final perStep = moveDelta / steps.toDouble(); + final stepDuration = + Duration(microseconds: duration.inMicroseconds ~/ steps); + + + for (int i = 0; i < steps; i++) { + pos1 += pinch ? perStep : -perStep; + pos2 += pinch ? -perStep : perStep; + + binding.handlePointerEvent(p1.move(pos1)); + binding.handlePointerEvent(p2.move(pos2)); + + await tester.pump(stepDuration); + } + + // Pointer up + binding.handlePointerEvent(p1.up()); + binding.handlePointerEvent(p2.up()); + + await tester.pump(); +} + + +Future performPinchGesture( + WidgetTester tester, + { + Offset center = Offset.zero, + Offset startSeparation = const Offset(50, 0), + Offset moveDelta = const Offset(15, 2), +}) async { + // Start two gestures on opposite sides of that center + final gesture1 = await tester.startGesture(center - startSeparation); + final gesture2 = await tester.startGesture(center + startSeparation); + + await tester.pump(); + + await gesture1.moveBy(moveDelta); + await gesture2.moveBy(-moveDelta); + 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 != isScaled) { + ++isScaledStateChange; + _wasScaled = isScaled; + } + } + + @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 != isScaled) { + ++isScaledStateChange; + _wasScaled = isScaled; + } + } +} + +// Source - https://stackoverflow.com/a +// Posted by Alexander Marochko +// 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, + double frequency = 60.0, + }) { + assert(frequency > 0); + final int intervals = duration.inMicroseconds * frequency ~/ 1E6; + assert(intervals > 1); + pointer ??= nextPointer; + int pointer2 = pointer + 1; + final List timeStamps = [ + for (int t = 0; t <= intervals; t += 1) + duration * t ~/ intervals, + ]; + final List offsets1 = [ + startLocation1, + for (int t = 0; t <= intervals; t += 1) + startLocation1 + offset1 * (t / intervals), + ]; + final List offsets2 = [ + startLocation2, + for (int t = 0; t <= intervals; t += 1) + startLocation2 + offset2 * (t / intervals), + ]; + final List 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); + } +} + +class _SimpleDragCallbacksComponent extends PositionComponent + with DragCallbacks { + _SimpleDragCallbacksComponent({super.size}); +} diff --git a/packages/flame_test/lib/src/mock_tap_drag_events.dart b/packages/flame_test/lib/src/mock_tap_drag_events.dart index b4454884531..567d1bdf921 100644 --- a/packages/flame_test/lib/src/mock_tap_drag_events.dart +++ b/packages/flame_test/lib/src/mock_tap_drag_events.dart @@ -103,3 +103,51 @@ DragUpdateEvent createDragUpdateEvents({ ), ); } + +ScaleStartEvent createScaleStartEvents({ + required Game game, + int? pointerId, + PointerDeviceKind? kind, + Offset? focalPoint, + Offset? localFocalPoint, + int? pointerCount, +}) { + return ScaleStartEvent( + pointerId ?? 1, + game, + ScaleStartDetails( + focalPoint: focalPoint ?? Offset.zero, + localFocalPoint: localFocalPoint ?? Offset.zero, + pointerCount: pointerCount ?? 1, + ), + ); +} + +ScaleUpdateEvent createScaleUpdateEvents({ + required Game game, + int? pointerId, + PointerDeviceKind? kind, + Offset? localFocalPoint, + Offset? focalPoint, + double? scale, + double? horizontalScale, + double? verticalScale, + double? rotation, + int? pointerCount, + Offset? focalPointDelta, +}) { + return ScaleUpdateEvent( + pointerId ?? 1, + game, + ScaleUpdateDetails( + localFocalPoint: localFocalPoint ?? Offset.zero, + focalPoint: focalPoint ?? Offset.zero, + scale: scale ?? 1, + horizontalScale: horizontalScale ?? 1, + verticalScale: verticalScale ?? 1, + rotation: rotation ?? 0, + pointerCount: pointerCount ?? 1, + focalPointDelta: focalPointDelta ?? Offset.zero + ), + ); +} From 987f9779ab327677889dc95798b283fa01d8a7b7 Mon Sep 17 00:00:00 2001 From: stilnat Date: Fri, 21 Nov 2025 01:07:39 +0100 Subject: [PATCH 13/87] add new test --- .../scale_callbacks_test.dart | 567 ++++++++++-------- 1 file changed, 301 insertions(+), 266 deletions(-) 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 8b702987824..443d44075aa 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'dart:ui'; import 'package:flame/components.dart'; @@ -21,26 +22,63 @@ void main() { ); }); testWithFlameGame('scale event start', (game) async { - final component = _ScaleCallbacksComponent() - ..x = 10 - ..y = 10 - ..width = 10 - ..height = 10; - game.add(component); - await game.ready(); + final component = _ScaleCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + game.add(component); + await game.ready(); - expect(game.children.whereType().length, 1); - game.firstChild()!.onScaleStart( - createScaleStartEvents( - game: game, - localFocalPoint: const Offset(12, 12), - focalPoint: const Offset(12, 12), - ), - ); - expect(component.containsLocalPoint(Vector2(10, 10)), false); - }); + expect(game.children.whereType().length, 1); + game.firstChild()!.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.containsLocalPoint(Vector2(10, 10)), false); + }); - testWithFlameGame('scale event start, update and end', (game) async { + testWithFlameGame('scale event start, update and end', (game) async { + final component = _ScaleCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.scaleStartEvent, 1); + expect(component.scaleUpdateEvent, 0); + expect(component.scaleEndEvent, 0); + + dispatcher.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)); + + dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + expect(component.scaleEndEvent, equals(1)); + }); + + testWithFlameGame( + 'scale event update not called without onScaleStart', + (game) async { final component = _ScaleCallbacksComponent() ..x = 10 ..y = 10 @@ -48,17 +86,8 @@ void main() { ..height = 10; await game.ensureAdd(component); final dispatcher = game.firstChild()!; - - dispatcher.onScaleStart( - createScaleStartEvents( - game: game, - localFocalPoint: const Offset(12, 12), - focalPoint: const Offset(12, 12), - ), - ); - expect(component.scaleStartEvent, 1); - expect(component.scaleUpdateEvent, 0); - expect(component.scaleEndEvent, 0); + expect(component.scaleStartEvent, equals(0)); + expect(component.scaleUpdateEvent, equals(0)); dispatcher.onScaleUpdate( createScaleUpdateEvents( @@ -67,149 +96,126 @@ void main() { focalPoint: const Offset(15, 15), ), ); - - expect(game.containsLocalPoint(Vector2(9, 9)), isTrue); - expect(component.scaleUpdateEvent, equals(1)); - - dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); - expect(component.scaleEndEvent, equals(1)); - }); - - testWithFlameGame( - 'scale event update not called without onScaleStart', - (game) async { - final component = _ScaleCallbacksComponent() - ..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)); - }, - ); - -testWidgets('scale correctly registered handled event', (tester) async { - final component = _ScaleCallbacksComponent() - ..x = 100 - ..y = 100 - ..width = 150 - ..height = 150; - final game = FlameGame(children: [component]); - - await tester.pumpWidget(GameWidget(game: game)); - await tester.pump(); - - await performPinchGesture(tester, - center: Offset(150, 150), - startSeparation: Offset(30, 0), - moveDelta: Offset(15, 2) + expect(component.scaleUpdateEvent, equals(0)); + }, ); - expect(game.children.length, equals(4)); - expect(component.isMounted, isTrue); - - expect(component.scaleStartEvent, equals(2)); - expect(component.scaleUpdateEvent, greaterThan(0)); - expect(component.scaleEndEvent, equals(2)); -}); - -testWidgets( - 'scale outside of component is not registered as handled', - (tester) async { - final component = _ScaleCallbacksComponent()..size = Vector2.all(100); + testWidgets('scale correctly registered handled event', (tester) async { + final component = _ScaleCallbacksComponent() + ..x = 100 + ..y = 100 + ..width = 150 + ..height = 150; final game = FlameGame(children: [component]); + await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); - await tester.pump(); - expect(component.isMounted, isTrue); - await performPinchGesture(tester, - center: Offset(200, 200), - startSeparation: Offset(50, 0), - moveDelta: Offset(15, 2) + await performPinchGesture( + tester, + center: const Offset(150, 150), + startSeparation: const Offset(30, 0), + moveDelta: const Offset(15, 2), ); - expect(component.scaleStartEvent, equals(0)); - expect(component.scaleUpdateEvent, equals(0)); - expect(component.scaleEndEvent, equals(0)); - }, -); + expect(game.children.length, equals(4)); + expect(component.isMounted, isTrue); -testWithGame( - 'make sure the FlameGame can registers Scale Callbacks on itself', - _ScaleCallbacksGame.new, - (game) async { - await game.ready(); - expect(game.children.length, equals(3)); - expect(game.children.elementAt(1), isA()); - }, -); - -testWidgets( - 'scale correctly registered handled event directly on FlameGame', - (tester) async { - 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); + expect(component.scaleStartEvent, equals(2)); + expect(component.scaleUpdateEvent, greaterThan(0)); + expect(component.scaleEndEvent, equals(2)); + }); - await performPinchGesture(tester, - center: Offset(100, 100), - startSeparation: Offset(50, 0), - moveDelta: Offset(15, 2) + testWidgets( + 'scale outside of component is not registered as handled', + (tester) async { + 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 performPinchGesture( + tester, + center: const Offset(200, 200), + startSeparation: const Offset(50, 0), + moveDelta: const Offset(15, 2), ); - expect(game.scaleStartEvent, equals(2)); - expect(game.scaleUpdateEvent, greaterThan(0)); - expect(game.scaleEndEvent, equals(2)); - }, -); + expect(component.scaleStartEvent, equals(0)); + expect(component.scaleUpdateEvent, equals(0)); + expect(component.scaleEndEvent, equals(0)); + }, + ); -testWidgets( - 'isDragged is changed', - (tester) async { - final component = _ScaleCallbacksComponent()..size = Vector2.all(100) - ..x = 100 - ..y = 100; + testWithGame( + 'make sure the FlameGame can registers Scale Callbacks on itself', + _ScaleCallbacksGame.new, + (game) async { + await game.ready(); + expect(game.children.length, equals(3)); + expect(game.children.elementAt(1), isA()); + }, + ); - final game = FlameGame(children: [component]); - await tester.pumpWidget(GameWidget(game: game)); - await tester.pump(); - await tester.pump(); + testWidgets( + 'scale correctly registered handled event directly on FlameGame', + (tester) async { + 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 performPinchGesture( + tester, + center: const Offset(100, 100), + startSeparation: const Offset(50, 0), + moveDelta: const Offset(15, 2), + ); - // Inside component - await performPinchGesture(tester, - center: Offset(150, 150), - startSeparation: Offset(30, 0), - moveDelta: Offset(15, 2) - ); + expect(game.scaleStartEvent, equals(2)); + expect(game.scaleUpdateEvent, greaterThan(0)); + expect(game.scaleEndEvent, equals(2)); + }, + ); - expect(component.isScaledStateChange, equals(4)); + testWidgets( + 'isDragged is changed', + (tester) async { + final component = _ScaleCallbacksComponent() + ..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 performPinchGesture( + tester, + center: const Offset(150, 150), + startSeparation: const Offset(30, 0), + moveDelta: const Offset(15, 2), + ); - // Outside component - await performPinchGesture(tester, - center: Offset(300, 300), - startSeparation: Offset(30, 0), - moveDelta: Offset(15, 2) - ); - expect(component.isScaledStateChange, equals(4)); - }, - ); - group('HasDraggableComponents', () { + expect(component.isScaledStateChange, equals(4)); + // Outside component + await performPinchGesture( + tester, + center: const Offset(300, 300), + startSeparation: const Offset(30, 0), + moveDelta: const Offset(15, 2), + ); + expect(component.isScaledStateChange, equals(4)); + }, + ); + group('HasDraggableComponents', () { testWidgets( 'drag event does not affect more than one component', (tester) async { @@ -222,16 +228,18 @@ testWidgets( onScaleUpdate: (e) => nEvents++, onScaleEnd: (e) => nEvents++, ), - _SimpleScaleCallbacksComponent(size: Vector2.all(200))..priority = 10, + _SimpleScaleCallbacksComponent(size: Vector2.all(200)) + ..priority = 10, ], ); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); - await performPinchGesture(tester, - center: Offset(50, 50), - startSeparation: Offset(30, 0), - moveDelta: Offset(15, 2) + await performPinchGesture( + tester, + center: const Offset(50, 50), + startSeparation: const Offset(30, 0), + moveDelta: const Offset(15, 2), ); expect(nEvents, 0); @@ -239,110 +247,144 @@ testWidgets( ); testWidgets( - 'drag event can move outside the component bounds and still fire', + 'scale event can move outside the component bounds and still fire', (tester) async { - final points = []; + var nEvents = 0; + const intervals = 50; + final component = _ScaleWithCallbacksComponent( + size: Vector2.all(30), + position: Vector2.all(100), + onScaleUpdate: (e) => nEvents++, + ); final game = FlameGame( - children: [ - _ScaleWithCallbacksComponent( - size: Vector2.all(500), - position: Vector2.all(5), - onScaleUpdate: (e) => points.add(Vector2.all(e.scale)), - ), - ], + children: [component], ); - // TODO isn't the issue actually localStartPosition ??? await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); - await tester.pump(); - - final center = Offset(150, 150); - await tester.timedZoomFrom( - center.translate(-10, 0), const Offset(-10, 0), - center.translate(10, 0), const Offset(10, 0), - const Duration(seconds: 1), - frequency: 10); - /*await performScaleGestureTimed( - tester, - pinch: false, - center: Offset(150, 150), - duration: Duration(milliseconds: 300), - steps: 15, - startSeparation: Offset(100, 0), - moveDelta: Offset(45, 2), - );*/ - //expect(points.length, 80); - debugPrint('${points.length}'); - expect(points.first, Vector2(75, 75)); - expect( - points.skip(1), - List.generate(41, (i) => Vector2(75.0, 75.0 + i)), + 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(nEvents, intervals * 2 + 2); }, ); }); - -} - -Future performScaleGestureTimed( - WidgetTester tester, { - required Offset center, - required bool pinch, - required Offset startSeparation, - required Offset moveDelta, - int steps = 10, - Duration duration = const Duration(milliseconds: 300), -}) async { - final binding = tester.binding; - final p1 = TestPointer(1, PointerDeviceKind.touch); - final p2 = TestPointer(2, PointerDeviceKind.touch); - - Offset pos1 = center - startSeparation; - Offset pos2 = center + startSeparation; + testWidgets( + 'scale event scale respects camera & zoom', + (tester) async { + final resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); + var scales = []; - // Pointer down - binding.handlePointerEvent(p1.down(pos1)); - binding.handlePointerEvent(p2.down(pos2)); + game.camera.viewfinder.zoom = 3; - final perStep = moveDelta / steps.toDouble(); - final stepDuration = - Duration(microseconds: duration.inMicroseconds ~/ steps); + await game.world.add( + _ScaleWithCallbacksComponent( + 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)); + }, + ); - for (int i = 0; i < steps; i++) { - pos1 += pinch ? perStep : -perStep; - pos2 += pinch ? -perStep : perStep; + testWidgets( + 'scale event rotation respects camera & zoom', + (tester) async { + final resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); + var rotations = []; - binding.handlePointerEvent(p1.move(pos1)); - binding.handlePointerEvent(p2.move(pos2)); + game.camera.viewfinder.zoom = 3; - await tester.pump(stepDuration); - } + await game.world.add( + _ScaleWithCallbacksComponent( + position: Vector2.all(-5), + size: Vector2.all(10), + onScaleUpdate: (event) { + rotations.add(event.rotation); + }, + ), + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final canvasSize = game.canvasSize; + + final center = (canvasSize / 2).toOffset(); + await tester.timedZoomFrom( + center.translate(-1, 0), + const Offset(0, 20), + center.translate(1, 0), + const Offset(0, -20), + const Duration(milliseconds: 300), + intervals: 10, + ); - // Pointer up - binding.handlePointerEvent(p1.up()); - binding.handlePointerEvent(p2.up()); + // computation of angle using trigonometry with triangle having a size + // of length 1 and one of length i. + final expected = List.generate(21, (i) => -atan(i)); - await tester.pump(); + // remove the first element that is registered twice in the simulation + rotations = rotations.sublist(1); + for (var i = 0; i < expected.length; i++) { + expect(rotations[i], closeTo(expected[i], 1e-6)); // tolerance + } + }, + ); } - Future performPinchGesture( - WidgetTester tester, - { - Offset center = Offset.zero, + WidgetTester tester, { + Offset center = Offset.zero, Offset startSeparation = const Offset(50, 0), Offset moveDelta = const Offset(15, 2), }) async { - // Start two gestures on opposite sides of that center + // Start two gestures on opposite sides of that center final gesture1 = await tester.startGesture(center - startSeparation); final gesture2 = await tester.startGesture(center + startSeparation); await tester.pump(); - await gesture1.moveBy(moveDelta); + await gesture1.moveBy(moveDelta); await gesture2.moveBy(-moveDelta); await tester.pump(); @@ -398,37 +440,34 @@ mixin _ScaleCounter on ScaleCallbacks { // Posted by Alexander Marochko // Retrieved 2025-11-19, License - CC BY-SA 4.0 -extension ZoomTesting on WidgetTester{ +extension ZoomTesting on WidgetTester { Future timedZoomFrom( - Offset startLocation1, - Offset offset1, - Offset startLocation2, - Offset offset2, - Duration duration, { - int? pointer, - int buttons = kPrimaryButton, - double frequency = 60.0, - }) { - assert(frequency > 0); - final int intervals = duration.inMicroseconds * frequency ~/ 1E6; + Offset startLocation1, + Offset offset1, + Offset startLocation2, + Offset offset2, + Duration duration, { + int? pointer, + int buttons = kPrimaryButton, + int intervals = 30, + }) { assert(intervals > 1); pointer ??= nextPointer; - int pointer2 = pointer + 1; - final List timeStamps = [ - for (int t = 0; t <= intervals; t += 1) - duration * t ~/ intervals, + var pointer2 = pointer + 1; + final timeStamps = [ + for (int t = 0; t <= intervals; t += 1) duration * t ~/ intervals, ]; - final List offsets1 = [ + final offsets1 = [ startLocation1, for (int t = 0; t <= intervals; t += 1) startLocation1 + offset1 * (t / intervals), ]; - final List offsets2 = [ + final offsets2 = [ startLocation2, for (int t = 0; t <= intervals; t += 1) startLocation2 + offset2 * (t / intervals), ]; - final List records = [ + final records = [ PointerEventRecord(Duration.zero, [ PointerAddedEvent( position: startLocation1, @@ -448,19 +487,19 @@ extension ZoomTesting on WidgetTester{ ), ]), ...[ - for(int t = 0; t <= intervals; t += 1) + 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], + 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], + position: offsets2[t + 1], + delta: offsets2[t + 1] - offsets2[t], pointer: pointer2, buttons: buttons, ), @@ -485,18 +524,19 @@ extension ZoomTesting on WidgetTester{ } } - class _ScaleCallbacksComponent extends PositionComponent with ScaleCallbacks, _ScaleCounter {} -class _ScaleCallbacksGame extends FlameGame 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 { +class _ScaleWithCallbacksComponent extends PositionComponent + with ScaleCallbacks { _ScaleWithCallbacksComponent({ void Function(ScaleStartEvent)? onScaleStart, void Function(ScaleUpdateEvent)? onScaleUpdate, @@ -527,9 +567,4 @@ class _ScaleWithCallbacksComponent extends PositionComponent with ScaleCallbacks super.onScaleEnd(event); return _onScaleEnd?.call(event); } -} - -class _SimpleDragCallbacksComponent extends PositionComponent - with DragCallbacks { - _SimpleDragCallbacksComponent({super.size}); -} +} \ No newline at end of file From f7b71531b63bd0945dcad2f364df4ddb3c916416 Mon Sep 17 00:00:00 2001 From: stilnat Date: Fri, 21 Nov 2025 01:16:41 +0100 Subject: [PATCH 14/87] format and change zoom api --- .../scale_callbacks_test.dart | 81 ++++++++++--------- 1 file changed, 44 insertions(+), 37 deletions(-) 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 443d44075aa..afadc7c6f62 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -111,11 +111,12 @@ void main() { await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); - await performPinchGesture( + await zoomFrom( tester, - center: const Offset(150, 150), - startSeparation: const Offset(30, 0), - moveDelta: const Offset(15, 2), + startLocation1: const Offset(180, 150), + offset1: const Offset(15, 2), + startLocation2: const Offset(120, 150), + offset2: const Offset(-15, -2), ); expect(game.children.length, equals(4)); @@ -136,11 +137,12 @@ void main() { await tester.pump(); expect(component.isMounted, isTrue); - await performPinchGesture( + await zoomFrom( tester, - center: const Offset(200, 200), - startSeparation: const Offset(50, 0), - moveDelta: const Offset(15, 2), + 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)); @@ -169,11 +171,12 @@ void main() { expect(game.children.length, equals(3)); expect(game.isMounted, isTrue); - await performPinchGesture( + await zoomFrom( tester, - center: const Offset(100, 100), - startSeparation: const Offset(50, 0), - moveDelta: const Offset(15, 2), + startLocation1: const Offset(50, 100), + offset1: const Offset(15, 2), + startLocation2: const Offset(150, 100), + offset2: const Offset(-15, -2), ); expect(game.scaleStartEvent, equals(2)); @@ -183,7 +186,7 @@ void main() { ); testWidgets( - 'isDragged is changed', + 'isScaled is changed', (tester) async { final component = _ScaleCallbacksComponent() ..size = Vector2.all(100) @@ -196,28 +199,31 @@ void main() { await tester.pump(); // Inside component - await performPinchGesture( + await zoomFrom( tester, - center: const Offset(150, 150), - startSeparation: const Offset(30, 0), - moveDelta: const Offset(15, 2), + startLocation1: const Offset(180, 100), + offset1: const Offset(15, 2), + startLocation2: const Offset(120, 100), + offset2: const Offset(-15, -2), ); expect(component.isScaledStateChange, equals(4)); // Outside component - await performPinchGesture( + await zoomFrom( tester, - center: const Offset(300, 300), - startSeparation: const Offset(30, 0), - moveDelta: const Offset(15, 2), + startLocation1: const Offset(330, 300), + offset1: const Offset(15, 2), + startLocation2: const Offset(270, 300), + offset2: const Offset(-15, -2), ); + expect(component.isScaledStateChange, equals(4)); }, ); - group('HasDraggableComponents', () { + group('HasScalableComponents', () { testWidgets( - 'drag event does not affect more than one component', + 'scale event does not affect more than one component', (tester) async { var nEvents = 0; final game = FlameGame( @@ -235,13 +241,13 @@ void main() { await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); - await performPinchGesture( + await zoomFrom( tester, - center: const Offset(50, 50), - startSeparation: const Offset(30, 0), - moveDelta: const Offset(15, 2), + startLocation1: const Offset(80, 50), + offset1: const Offset(15, 2), + startLocation2: const Offset(20, 50), + offset2: const Offset(-15, -2), ); - expect(nEvents, 0); }, ); @@ -372,20 +378,21 @@ void main() { ); } -Future performPinchGesture( +Future zoomFrom( WidgetTester tester, { - Offset center = Offset.zero, - Offset startSeparation = const Offset(50, 0), - Offset moveDelta = const Offset(15, 2), + 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(center - startSeparation); - final gesture2 = await tester.startGesture(center + startSeparation); + final gesture1 = await tester.startGesture(startLocation1); + final gesture2 = await tester.startGesture(startLocation2); await tester.pump(); - await gesture1.moveBy(moveDelta); - await gesture2.moveBy(-moveDelta); + await gesture1.moveBy(offset1); + await gesture2.moveBy(offset2); await tester.pump(); // release fingers @@ -567,4 +574,4 @@ class _ScaleWithCallbacksComponent extends PositionComponent super.onScaleEnd(event); return _onScaleEnd?.call(event); } -} \ No newline at end of file +} From cbc233354fed41b2bae1b4216fea2e8cea2ad9ac Mon Sep 17 00:00:00 2001 From: stilnat Date: Fri, 21 Nov 2025 01:22:24 +0100 Subject: [PATCH 15/87] fix name not recognised --- .../test/events/component_mixins/scale_callbacks_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 afadc7c6f62..a9dafe65b01 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -443,8 +443,8 @@ mixin _ScaleCounter on ScaleCallbacks { } } -// Source - https://stackoverflow.com/a -// Posted by Alexander Marochko +// Source - https://stackoverflow.com/a/75171528 +// Posted by Alexander // Retrieved 2025-11-19, License - CC BY-SA 4.0 extension ZoomTesting on WidgetTester { From 6162fb3bfc28485da333637a690d12c518bfb681 Mon Sep 17 00:00:00 2001 From: stilnat Date: Fri, 21 Nov 2025 01:27:25 +0100 Subject: [PATCH 16/87] format fix --- .../test/events/component_mixins/scale_callbacks_test.dart | 5 ++--- packages/flame_test/lib/src/mock_tap_drag_events.dart | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) 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 a9dafe65b01..22290745478 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -1,5 +1,4 @@ import 'dart:math'; -import 'dart:ui'; import 'package:flame/components.dart'; import 'package:flame/events.dart' hide PointerMoveEvent; @@ -293,7 +292,7 @@ void main() { height: resolution.y, ), ); - var scales = []; + final scales = []; game.camera.viewfinder.zoom = 3; @@ -460,7 +459,7 @@ extension ZoomTesting on WidgetTester { }) { assert(intervals > 1); pointer ??= nextPointer; - var pointer2 = pointer + 1; + final pointer2 = pointer + 1; final timeStamps = [ for (int t = 0; t <= intervals; t += 1) duration * t ~/ intervals, ]; diff --git a/packages/flame_test/lib/src/mock_tap_drag_events.dart b/packages/flame_test/lib/src/mock_tap_drag_events.dart index 567d1bdf921..8256e5c3559 100644 --- a/packages/flame_test/lib/src/mock_tap_drag_events.dart +++ b/packages/flame_test/lib/src/mock_tap_drag_events.dart @@ -147,7 +147,7 @@ ScaleUpdateEvent createScaleUpdateEvents({ verticalScale: verticalScale ?? 1, rotation: rotation ?? 0, pointerCount: pointerCount ?? 1, - focalPointDelta: focalPointDelta ?? Offset.zero + focalPointDelta: focalPointDelta ?? Offset.zero, ), ); } From f945eea70a2ca095673812ba1912fb5bfa7ccad6 Mon Sep 17 00:00:00 2001 From: stilnat Date: Fri, 21 Nov 2025 21:07:29 +0100 Subject: [PATCH 17/87] fix melos analyse --- .../scale_callbacks_test.dart | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) 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 22290745478..b40f58d6598 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -110,7 +110,7 @@ void main() { await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); - await zoomFrom( + await _zoomFrom( tester, startLocation1: const Offset(180, 150), offset1: const Offset(15, 2), @@ -136,7 +136,7 @@ void main() { await tester.pump(); expect(component.isMounted, isTrue); - await zoomFrom( + await _zoomFrom( tester, startLocation1: const Offset(250, 200), offset1: const Offset(15, 2), @@ -170,7 +170,7 @@ void main() { expect(game.children.length, equals(3)); expect(game.isMounted, isTrue); - await zoomFrom( + await _zoomFrom( tester, startLocation1: const Offset(50, 100), offset1: const Offset(15, 2), @@ -198,7 +198,7 @@ void main() { await tester.pump(); // Inside component - await zoomFrom( + await _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 _zoomFrom( tester, startLocation1: const Offset(330, 300), offset1: const Offset(15, 2), @@ -240,7 +240,7 @@ void main() { await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); - await zoomFrom( + await _zoomFrom( tester, startLocation1: const Offset(80, 50), offset1: const Offset(15, 2), @@ -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), @@ -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), @@ -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), @@ -375,31 +375,33 @@ 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(); + 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 gesture1.moveBy(offset1); - await gesture2.moveBy(offset2); - await tester.pump(); + await tester.pump(); - // release fingers - await gesture1.up(); - await gesture2.up(); + await gesture1.moveBy(offset1); + await gesture2.moveBy(offset2); + await tester.pump(); - await tester.pump(); -} + // release fingers + await gesture1.up(); + await gesture2.up(); + + await tester.pump(); + } mixin _ScaleCounter on ScaleCallbacks { int scaleStartEvent = 0; @@ -446,8 +448,8 @@ mixin _ScaleCounter on ScaleCallbacks { // Posted by Alexander // Retrieved 2025-11-19, License - CC BY-SA 4.0 -extension ZoomTesting on WidgetTester { - Future timedZoomFrom( +extension _ZoomTesting on WidgetTester { + Future _timedZoomFrom( Offset startLocation1, Offset offset1, Offset startLocation2, From 8ddce28103968829d701ee17a5c64e1bf24294f6 Mon Sep 17 00:00:00 2001 From: stilnat Date: Fri, 21 Nov 2025 21:17:08 +0100 Subject: [PATCH 18/87] format stuff --- .../scale_callbacks_test.dart | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) 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 b40f58d6598..451e980d306 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -375,33 +375,31 @@ 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); - 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 tester.pump(); - await gesture1.moveBy(offset1); - await gesture2.moveBy(offset2); - await tester.pump(); + await gesture1.moveBy(offset1); + await gesture2.moveBy(offset2); + await tester.pump(); - // release fingers - await gesture1.up(); - await gesture2.up(); + // release fingers + await gesture1.up(); + await gesture2.up(); - await tester.pump(); - } + await tester.pump(); +} mixin _ScaleCounter on ScaleCallbacks { int scaleStartEvent = 0; From bf69b48c61fdd4cf5a953140951e6cf4b5e024e6 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 22 Nov 2025 20:13:48 +0100 Subject: [PATCH 19/87] fix vector 2 creations --- .../flame_game_mixins/scale_dispatcher.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 90d7788ed13..a68b21a62ad 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 @@ -225,13 +225,14 @@ class ScaleDispatcher extends Component implements ScaleListener { return 1.0; } - final currentLineDistance = _currentLine!.pointerStartLocation - .toVector2() - .distanceTo(_currentLine!.pointerEndLocation.toVector2()); - - final firstLineDistance = _lineAtFirstUpdate!.pointerStartLocation - .toVector2() - .distanceTo(_lineAtFirstUpdate!.pointerEndLocation.toVector2()); + final currentLineDistance = + (_currentLine!.pointerStartLocation - _currentLine!.pointerEndLocation) + .distance; + + final firstLineDistance = + (_lineAtFirstUpdate!.pointerStartLocation - + _lineAtFirstUpdate!.pointerEndLocation) + .distance; return currentLineDistance / firstLineDistance; } From 5369f7981a2df2c9355808b4e00f3985b321ec86 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 22 Nov 2025 21:18:14 +0100 Subject: [PATCH 20/87] add doc --- doc/flame/inputs/scale_events.md | 177 +++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 doc/flame/inputs/scale_events.md diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md new file mode 100644 index 00000000000..052219123c1 --- /dev/null +++ b/doc/flame/inputs/scale_events.md @@ -0,0 +1,177 @@ +# Scale Events + +**Scale events** occur when the user moves two fingers in a pinch in, or in a pinch out move. +Only one single scale gesture can occur at the same time. + + +For those components that you want to respond to scale events, add the `ScaleCallbacks` mixin. + +- This mixin adds three overridable methods to your component: `onScaleStart`, `onScaleUpdate`, + `onScaleEnd`. By default, these methods do nothing -- they need to be + overridden in order to perform any function. +- In addition, the component must implement the `containsLocalPoint()` method (already implemented + in `PositionComponent`, so most of the time you don't need to do anything here) -- this method + allows Flame to know whether the event occurred within the component or not. + +```dart +class MyComponent extends PositionComponent with ScaleCallbacks { + MyComponent() : super(size: Vector2(180, 120)); + + @override + void onScaleStart(ScaleStartEvent event) { + // Do something in response to a scale event + } +} +``` + + +## Scale anatomy + + +### onScaleStart + +This is the first event that occurs in a scale sequence. Usually, the event will be delivered to the +topmost component at the focal point (the point at the center of the line formed by the two fingers) + with the `ScaleCallbacks` mixin. However, by setting the flag +`event.continuePropagation` to true, you can allow the event to propagate to the components below. + +The `ScaleStartEvent` object associated with this event will contain the coordinate of the first focal point +recognised by the scale gesture recogniser. This point is available in multiple coordinate system: +`devicePosition` is given in the coordinate system of the entire device, `canvasPosition` is in the +coordinate system of the game widget, and `localPosition` provides the position in the component's +local coordinate system. + +Any component that receives `onScaleStart` will later be receiving `onScaleUpdate` and `onScaleEnd` +events as well. + + +### onScaleUpdate + +This event is fired continuously as user drags their finger across the screen. It will not fire if +the user is holding their finger still. + +The default implementation delivers this event to all the components that received the previous +`onScaleStart`. If the point of touch is still within the component, then +`event.localPosition` will give the position of that point in the local coordinate system. However, +if the user moves their finger away from the component, the property `event.localPosition` will +return a point whose coordinates are NaNs. Likewise, the `event.renderingTrace` in this case will be +empty. However, the `canvasPosition` and `devicePosition` properties of the event will be valid. + +In addition, the `ScaleUpdateEvent` will contain `focalPointDelta` -- the amount the focal point has moved since the +previous `onScaleUpdate`, or since the `onScaleStart` if this is the first scale-update after a scale- +start. + +The `event.timestamp` property measures the time elapsed since the beginning of the scale. It can be +used, for example, to compute the speed of the movement. + +The `event.rotation` property measures the angle of rotation in radians, between the line formed +from the two fingers at the start, and the line formed when this event is called. + +The `event.scale` property measures the ratio of length between the line formed +from the two fingers at the start, and the line formed when this event is called. + + +### onScaleEnd + +This event is fired when the user lifts their finger and thus stops the scale gesture. There is no +position associated with this event. + +## Mixins + + +### ScaleCallbacks + +The `ScaleCallbacks` mixin can be added to any `Component` in order for that component to start +receiving scale events. + +This mixin adds methods `onScaleStart`, `onScaleUpdate`, `onScaleEnd` to the +component, which by default don't do anything, but can be overridden to implement any real +functionality. + +Another crucial detail is that a component will only receive scale events that originate *within* +that component, as judged by the `containsLocalPoint()` function. The commonly-used +`PositionComponent` class provides such an implementation based on its `size` property. Thus, if +your component derives from a `PositionComponent`, then make sure that you set its size correctly. +If, however, your component derives from the bare `Component`, then the `containsLocalPoint()` +method must be implemented manually. + +If your component is a part of a larger hierarchy, then it will only receive scale events if its +ancestors have all implemented the `containsLocalPoint` correctly. + +```dart +class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { + ScaleOnlyRectangle({ + required Vector2 position, + required Vector2 size, + Color color = Colors.blue, + Anchor anchor = Anchor.center, + }) : super( + position: position, + size: size, + anchor: anchor, + paint: Paint()..color = color, + ); + + @override + Future onLoad() async { + final text = TextComponent( + text: 'scale', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 25, color: Colors.white), + ), + position: size / 2, + anchor: Anchor.center, + ); + add(text); + } + + bool 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}'); + } + + @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}'); + } +} + +``` + +## 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. From 0d103324088cf53b9aaacf70caf691e91103bf7d Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 22 Nov 2025 21:37:51 +0100 Subject: [PATCH 21/87] markdown fixes --- doc/flame/inputs/scale_events.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 052219123c1..4b2e9f13085 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -35,8 +35,9 @@ topmost component at the focal point (the point at the center of the line formed with the `ScaleCallbacks` mixin. However, by setting the flag `event.continuePropagation` to true, you can allow the event to propagate to the components below. -The `ScaleStartEvent` object associated with this event will contain the coordinate of the first focal point -recognised by the scale gesture recogniser. This point is available in multiple coordinate system: +The `ScaleStartEvent` object associated with this event will contain +the coordinate of the first focal point +recognized by the scale gesture recognizer. This point is available in multiple coordinate system: `devicePosition` is given in the coordinate system of the entire device, `canvasPosition` is in the coordinate system of the game widget, and `localPosition` provides the position in the component's local coordinate system. @@ -57,17 +58,18 @@ if the user moves their finger away from the component, the property `event.loca return a point whose coordinates are NaNs. Likewise, the `event.renderingTrace` in this case will be empty. However, the `canvasPosition` and `devicePosition` properties of the event will be valid. -In addition, the `ScaleUpdateEvent` will contain `focalPointDelta` -- the amount the focal point has moved since the +In addition, the `ScaleUpdateEvent` will contain `focalPointDelta` -- +the amount the focal point has moved since the previous `onScaleUpdate`, or since the `onScaleStart` if this is the first scale-update after a scale- start. The `event.timestamp` property measures the time elapsed since the beginning of the scale. It can be used, for example, to compute the speed of the movement. -The `event.rotation` property measures the angle of rotation in radians, between the line formed +The `event.rotation` property measures the angle of rotation in radians, between the line formed from the two fingers at the start, and the line formed when this event is called. -The `event.scale` property measures the ratio of length between the line formed +The `event.scale` property measures the ratio of length between the line formed from the two fingers at the start, and the line formed when this event is called. @@ -76,6 +78,7 @@ from the two fingers at the start, and the line formed when this event is called This event is fired when the user lifts their finger and thus stops the scale gesture. There is no position associated with this event. + ## Mixins @@ -170,8 +173,14 @@ 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. + +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. From 4441db24b9cf2221ae95de5ee440cfe92aeef826 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 22 Nov 2025 21:40:19 +0100 Subject: [PATCH 22/87] fix markdown --- doc/flame/inputs/scale_events.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 4b2e9f13085..53a553bd4d6 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -58,7 +58,7 @@ if the user moves their finger away from the component, the property `event.loca return a point whose coordinates are NaNs. Likewise, the `event.renderingTrace` in this case will be empty. However, the `canvasPosition` and `devicePosition` properties of the event will be valid. -In addition, the `ScaleUpdateEvent` will contain `focalPointDelta` -- +In addition, the `ScaleUpdateEvent` will contain `focalPointDelta` -- the amount the focal point has moved since the previous `onScaleUpdate`, or since the `onScaleStart` if this is the first scale-update after a scale- start. @@ -180,7 +180,7 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { 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. +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. From 47eb614cf37606be0a8fb15a7268b80724fd0b1f Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 22 Nov 2025 21:44:22 +0100 Subject: [PATCH 23/87] rename is scaled --- examples/lib/stories/input/scale_example.dart | 6 +++--- .../lib/src/events/component_mixins/scale_callbacks.dart | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_example.dart index e6bee5312f9..6d83bb37276 100644 --- a/examples/lib/stories/input/scale_example.dart +++ b/examples/lib/stories/input/scale_example.dart @@ -250,7 +250,7 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { add(text); } - bool isScaling = false; + bool isDoingScaling = false; double initialAngle = 0; Vector2 initialScale = Vector2.all(1); double lastScale = 1.0; @@ -259,7 +259,7 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { @override void onScaleStart(ScaleStartEvent event) { super.onScaleStart(event); - isScaling = true; + isDoingScaling = true; initialAngle = angle; initialScale = scale; lastScale = 1.0; @@ -288,7 +288,7 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { @override void onScaleEnd(ScaleEndEvent event) { super.onScaleEnd(event); - isScaling = false; + isDoingScaling = false; debugPrint('Scale ended with velocity ${event.velocity}'); } } 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 4d5dddef5ae..7d4c5ba8a5d 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -4,21 +4,21 @@ import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flutter/foundation.dart'; mixin ScaleCallbacks on Component { - bool _isScaled = false; + bool _isScaling = false; /// Returns true while the component is being scaled. - bool get isScaled => _isScaled; + bool get isScaling => _isScaling; @mustCallSuper void onScaleStart(ScaleStartEvent event) { - _isScaled = true; + _isScaling = true; } void onScaleUpdate(ScaleUpdateEvent event) {} @mustCallSuper void onScaleEnd(ScaleEndEvent event) { - _isScaled = false; + _isScaling = false; } @override From 64fe3080e4361dd6a6e19451215bb42b0596c387 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 22 Nov 2025 21:48:10 +0100 Subject: [PATCH 24/87] analyse fix --- .../src/events/flame_game_mixins/scale_dispatcher.dart | 1 - .../events/component_mixins/scale_callbacks_test.dart | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) 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 a68b21a62ad..3a59887099a 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 @@ -6,7 +6,6 @@ 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:flame/src/image_composition.dart'; import 'package:flutter/gestures.dart'; import 'package:meta/meta.dart'; 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 451e980d306..aae99b33148 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -416,9 +416,9 @@ mixin _ScaleCounter on ScaleCallbacks { expect(event.raw, isNotNull); event.handled = true; scaleStartEvent++; - if (_wasScaled != isScaled) { + if (_wasScaled != isScaling) { ++isScaledStateChange; - _wasScaled = isScaled; + _wasScaled = isScaling; } } @@ -435,9 +435,9 @@ mixin _ScaleCounter on ScaleCallbacks { expect(event.raw, isNotNull); event.handled = true; scaleEndEvent++; - if (_wasScaled != isScaled) { + if (_wasScaled != isScaling) { ++isScaledStateChange; - _wasScaled = isScaled; + _wasScaled = isScaling; } } } From 42f0d18c0091b21cc19901c97764ad5a25c56b8b Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 22 Nov 2025 21:52:15 +0100 Subject: [PATCH 25/87] fix lint --- examples/lib/stories/input/scale_example.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_example.dart index 6d83bb37276..6db966a50bf 100644 --- a/examples/lib/stories/input/scale_example.dart +++ b/examples/lib/stories/input/scale_example.dart @@ -91,7 +91,7 @@ class InteractiveRectangle extends RectangleComponent paint: Paint()..color = color, ); - bool isScaling = false; + bool isDoingScaling = false; double initialAngle = 0; Vector2 initialScale = Vector2.all(1); double lastScale = 1.0; @@ -137,7 +137,7 @@ class InteractiveRectangle extends RectangleComponent @override void onScaleStart(ScaleStartEvent event) { super.onScaleStart(event); - isScaling = true; + isDoingScaling = true; initialAngle = angle; initialScale = scale; lastScale = 1.0; @@ -167,7 +167,7 @@ class InteractiveRectangle extends RectangleComponent @override void onScaleEnd(ScaleEndEvent event) { super.onScaleEnd(event); - isScaling = false; + isDoingScaling = false; debugPrint('Scale ended with velocity ${event.velocity}'); } } From d5b0086deae245888f8171bdfe0bee33ea07ebb9 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sat, 22 Nov 2025 23:52:12 +0100 Subject: [PATCH 26/87] add stuff about scale drag dispatcher --- examples/lib/stories/input/scale_example.dart | 560 +++++++++++++++++- .../flame_game_mixins/scale_dispatcher.dart | 1 + .../scale_drag_dispatcher.dart | 190 ++++++ .../events/multi_drag_scale_recognizer.dart | 0 4 files changed, 744 insertions(+), 7 deletions(-) create mode 100644 packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart create mode 100644 packages/flame/lib/src/events/multi_drag_scale_recognizer.dart diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_example.dart index 6db966a50bf..5b85addbd5f 100644 --- a/examples/lib/stories/input/scale_example.dart +++ b/examples/lib/stories/input/scale_example.dart @@ -1,13 +1,28 @@ import 'dart:math'; -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/game.dart'; -import 'package:flutter/material.dart'; +import 'dart:math' as math; +import 'package:flame/components.dart' hide Matrix4; +import 'package:flame/events.dart' hide PointerMoveEvent; +import 'package:flame/game.dart' hide Matrix4; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' hide PointerMoveEvent, Matrix4; void main() { runApp(GameWidget(game: ScaleExample())); } +class MyDrag extends Drag { + @override + void update(DragUpdateDetails details) { + // This specific finger moved by details.delta + print('This finger moved: ${details.delta}'); + } + + @override + void end(DragEndDetails details) { + print('This finger lifted'); + } +} + class ScaleExample extends FlameGame { late RectangleComponent rect; late TextComponent debugText; @@ -16,15 +31,39 @@ class ScaleExample extends FlameGame { Vector2 zoomCenter = Vector2.zero(); double startingZoom = 1; - final bool addScaleOnlyRectangle = true; - final bool addDragOnlyRectangle = true; - final bool addScaleDragRectangle = true; + final bool addScaleOnlyRectangle = false; + final bool addDragOnlyRectangle = false; + final bool addScaleDragRectangle = false; final bool addZoom = false; final bool addCameraRotation = false; + + Drag onDragStart(Offset initialPosition) { + debugPrint('Drag started'); + return MyDrag(); + } + + void onScaleStart(DragEndEvent event) { + debugPrint('Drag ended with velocity ${event.velocity}'); + } + + void onScaleUpdate(ScaleUpdateDetails event) { + debugPrint('scale update'); + } + @override Future onLoad() async { camera.viewfinder.zoom = 1; + (parent! as FlameGame).gestureDetectors.add( + MultiDragScaleGestureRecognizer.new, + (MultiDragScaleGestureRecognizer instance) { + instance + ..onDragStart = onDragStart + ..onScaleUpdate = onScaleUpdate; + }, + ); + + debugText = TextComponent( text: 'hello', @@ -292,3 +331,510 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { debugPrint('Scale ended with velocity ${event.velocity}'); } } + +/// A gesture recognizer that can recognize both individual pointer drags +/// and scale gestures simultaneously. +/// +/// This recognizer tracks each pointer individually (like ImmediateMultiDragGestureRecognizer) +/// while also tracking the overall scale gesture (like ScaleGestureRecognizer). +/// When 2+ pointers are down, both drag callbacks (per pointer) and scale callbacks fire. +class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { + /// Create a gesture recognizer for tracking multi-drag and scale gestures. + MultiDragScaleGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + super.allowedButtonsFilter, + this.dragStartBehavior = DragStartBehavior.down, + this.scaleThreshold = 1.05, + }); + + /// 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? onDragStart; + + /// 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; + + _GestureState _state = _GestureState.ready; + bool _scaleGestureActive = false; + + final Map _pointerLocations = {}; + final Map _initialPointerLocations = {}; + final List _pointerQueue = []; + final Map _velocityTrackers = {}; + final Map _activeDrags = {}; + final Map _lastPointerPositions = {}; + + // Scale-specific fields + late Offset _initialFocalPoint; + Offset? _currentFocalPoint; + late double _initialSpan; + late double _currentSpan; + late double _initialHorizontalSpan; + late double _currentHorizontalSpan; + late double _initialVerticalSpan; + late double _currentVerticalSpan; + late Offset _localFocalPoint; + _LineBetweenPointers? _initialLine; + _LineBetweenPointers? _currentLine; + Matrix4? _lastTransform; + late Offset _delta; + VelocityTracker? _scaleVelocityTracker; + Duration? _initialScaleEventTimestamp; + + int get pointerCount => _pointerLocations.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) { + super.addAllowedPointer(event); + _velocityTrackers[event.pointer] = VelocityTracker.withKind(event.kind); + _initialPointerLocations[event.pointer] = event.position; + _lastPointerPositions[event.pointer] = event.position; + + if (_state == _GestureState.ready) { + _state = _GestureState.possible; + _initialSpan = 0.0; + _currentSpan = 0.0; + _initialHorizontalSpan = 0.0; + _currentHorizontalSpan = 0.0; + _initialVerticalSpan = 0.0; + _currentVerticalSpan = 0.0; + } + } + + @override + void handleEvent(PointerEvent event) { + assert(_state != _GestureState.ready); + bool didChangeConfiguration = false; + bool shouldStartIfAccepted = false; + + if (event is PointerMoveEvent) { + final VelocityTracker tracker = _velocityTrackers[event.pointer]!; + if (!event.synthesized) { + tracker.addPosition(event.timeStamp, event.position); + } + + final Offset lastPosition = _lastPointerPositions[event.pointer]!; + final Offset delta = event.position - lastPosition; + _lastPointerPositions[event.pointer] = event.position; + + // Update individual drag + final Drag? drag = _activeDrags[event.pointer]; + if (drag != null) { + drag.update(DragUpdateDetails( + globalPosition: event.position, + delta: delta, + primaryDelta: delta.dy, + sourceTimeStamp: event.timeStamp, + localPosition: PointerEvent.transformPosition(event.transform, event.position), + )); + } + + _pointerLocations[event.pointer] = event.position; + shouldStartIfAccepted = true; + _lastTransform = event.transform; + } else if (event is PointerDownEvent) { + _pointerLocations[event.pointer] = event.position; + _pointerQueue.add(event.pointer); + didChangeConfiguration = true; + shouldStartIfAccepted = true; + _lastTransform = event.transform; + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + // End individual drag + final Drag? drag = _activeDrags[event.pointer]; + if (drag != null) { + final VelocityTracker tracker = _velocityTrackers[event.pointer]!; + if (event is PointerUpEvent) { + drag.end(DragEndDetails( + velocity: tracker.getVelocity(), + primaryVelocity: tracker.getVelocity().pixelsPerSecond.dy, + )); + } else { + drag.cancel(); + } + _activeDrags.remove(event.pointer); + } + + _pointerLocations.remove(event.pointer); + _initialPointerLocations.remove(event.pointer); + _lastPointerPositions.remove(event.pointer); + _pointerQueue.remove(event.pointer); + didChangeConfiguration = true; + _lastTransform = event.transform; + } + + _updateLines(); + _update(); + + if (!didChangeConfiguration || _reconfigure(event.pointer)) { + _advanceStateMachine(shouldStartIfAccepted, event); + } + stopTrackingIfPointerNoLongerDown(event); + } + + void _update() { + final Offset? previousFocalPoint = _currentFocalPoint; + + // Compute the focal point + Offset focalPoint = Offset.zero; + for (final int pointer in _pointerLocations.keys) { + focalPoint += _pointerLocations[pointer]!; + } + _currentFocalPoint = _pointerLocations.isEmpty + ? Offset.zero + : focalPoint / _pointerLocations.length.toDouble(); + + if (previousFocalPoint == null) { + _localFocalPoint = PointerEvent.transformPosition( + _lastTransform, _currentFocalPoint!); + _delta = Offset.zero; + } else { + final Offset localPreviousFocalPoint = _localFocalPoint; + _localFocalPoint = PointerEvent.transformPosition( + _lastTransform, _currentFocalPoint!); + _delta = _localFocalPoint - localPreviousFocalPoint; + } + + final int count = _pointerLocations.keys.length; + Offset pointerFocalPoint = Offset.zero; + for (final int pointer in _pointerLocations.keys) { + pointerFocalPoint += _pointerLocations[pointer]!; + } + if (count > 0) { + pointerFocalPoint = pointerFocalPoint / count.toDouble(); + } + + // Calculate span + double totalDeviation = 0.0; + double totalHorizontalDeviation = 0.0; + double totalVerticalDeviation = 0.0; + for (final int pointer in _pointerLocations.keys) { + totalDeviation += (pointerFocalPoint - _pointerLocations[pointer]!).distance; + totalHorizontalDeviation += + (pointerFocalPoint.dx - _pointerLocations[pointer]!.dx).abs(); + totalVerticalDeviation += + (pointerFocalPoint.dy - _pointerLocations[pointer]!.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 int count = _pointerLocations.keys.length; + assert(_pointerQueue.length >= count); + + if (count < 2) { + _initialLine = _currentLine; + } else if (_initialLine != null && + _initialLine!.pointerStartId == _pointerQueue[0] && + _initialLine!.pointerEndId == _pointerQueue[1]) { + _currentLine = _LineBetweenPointers( + pointerStartId: _pointerQueue[0], + pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, + pointerEndId: _pointerQueue[1], + pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + ); + } else { + _initialLine = _LineBetweenPointers( + pointerStartId: _pointerQueue[0], + pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, + pointerEndId: _pointerQueue[1], + pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + ); + _currentLine = _initialLine; + } + } + + bool _reconfigure(int pointer) { + _initialFocalPoint = _currentFocalPoint!; + _initialSpan = _currentSpan; + _initialLine = _currentLine; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + + if (_state == _GestureState.started && _scaleGestureActive) { + // Check if we should end the scale gesture + if (_pointerLocations.length < 2) { + if (onScaleEnd != null) { + final VelocityTracker tracker = _velocityTrackers[pointer]!; + Velocity velocity = tracker.getVelocity(); + + if (_isFlingGesture(velocity)) { + final Offset pixelsPerSecond = velocity.pixelsPerSecond; + if (pixelsPerSecond.distanceSquared > + kMaxFlingVelocity * kMaxFlingVelocity) { + velocity = Velocity( + pixelsPerSecond: + (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity, + ); + } + invokeCallback('onScaleEnd', () => onScaleEnd!( + ScaleEndDetails( + velocity: velocity, + scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, + pointerCount: pointerCount, + ), + )); + } else { + invokeCallback('onScaleEnd', () => onScaleEnd!( + ScaleEndDetails( + scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, + pointerCount: pointerCount, + ), + )); + } + } + _scaleGestureActive = false; + _state = _GestureState.accepted; + _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); + return false; + } + } + + _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); + return true; + } + + void _advanceStateMachine(bool shouldStartIfAccepted, PointerEvent event) { + if (_state == _GestureState.ready) { + _state = _GestureState.possible; + } + + if (_state == _GestureState.possible) { + // Check if we should start accepting gestures + bool shouldAccept = false; + + if (_pointerLocations.length >= 2) { + final double spanDelta = (_currentSpan - _initialSpan).abs(); + final double scaleFactor = _pointerScaleFactor; + + if (spanDelta > computeScaleSlop(event.kind) || + math.max(scaleFactor, 1.0 / scaleFactor) > scaleThreshold) { + shouldAccept = true; + } + } else if (_pointerLocations.length == 1) { + final int pointer = _pointerLocations.keys.first; + final Offset initialPosition = _initialPointerLocations[pointer]!; + final Offset currentPosition = _pointerLocations[pointer]!; + final double distance = (currentPosition - initialPosition).distance; + + if (distance > computePanSlop(event.kind, gestureSettings)) { + shouldAccept = true; + } + } + + if (shouldAccept) { + resolve(GestureDisposition.accepted); + } + } else if (_state.index >= _GestureState.accepted.index) { + resolve(GestureDisposition.accepted); + } + + if (_state == _GestureState.accepted && shouldStartIfAccepted) { + _state = _GestureState.started; + + // Start individual drags for all pointers that don't have one yet + for (final int pointer in _pointerLocations.keys) { + if (!_activeDrags.containsKey(pointer)) { + _startDragForPointer(pointer); + } + } + + // Start scale gesture if we have 2+ pointers + if (_pointerLocations.length >= 2 && !_scaleGestureActive) { + _scaleGestureActive = true; + _initialScaleEventTimestamp = event.timeStamp; + _dispatchOnScaleStartCallback(); + } + } + + if (_state == _GestureState.started) { + // Update scale gesture if active + if (_scaleGestureActive && _pointerLocations.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, + )); + }); + } + } else if (!_scaleGestureActive && _pointerLocations.length >= 2) { + // We just got a second pointer, start scale gesture + _scaleGestureActive = true; + _initialScaleEventTimestamp = event.timeStamp; + _dispatchOnScaleStartCallback(); + } + } + } + + void _startDragForPointer(int pointer) { + if (onDragStart != null) { + final Offset initialPosition = _initialPointerLocations[pointer]!; + final Drag? drag = invokeCallback('onDragStart', () { + return onDragStart!(initialPosition); + }); + _activeDrags[pointer] = drag; + } + } + + void _dispatchOnScaleStartCallback() { + if (onScaleStart != null) { + invokeCallback('onScaleStart', () { + onScaleStart!(ScaleStartDetails( + focalPoint: _currentFocalPoint!, + localFocalPoint: _localFocalPoint, + pointerCount: pointerCount, + sourceTimeStamp: _initialScaleEventTimestamp, + )); + }); + } + } + + double _computeRotationFactor() { + double factor = 0.0; + if (_initialLine != null && _currentLine != null) { + final double fx = _initialLine!.pointerStartLocation.dx; + final double fy = _initialLine!.pointerStartLocation.dy; + final double sx = _initialLine!.pointerEndLocation.dx; + final double sy = _initialLine!.pointerEndLocation.dy; + + final double nfx = _currentLine!.pointerStartLocation.dx; + final double nfy = _currentLine!.pointerStartLocation.dy; + final double nsx = _currentLine!.pointerEndLocation.dx; + final double nsy = _currentLine!.pointerEndLocation.dy; + + final double angle1 = math.atan2(fy - sy, fx - sx); + final double angle2 = math.atan2(nfy - nsy, nfx - nsx); + + factor = angle2 - angle1; + } + return factor; + } + + bool _isFlingGesture(Velocity velocity) { + final double speedSquared = velocity.pixelsPerSecond.distanceSquared; + return speedSquared > kMinFlingVelocity * kMinFlingVelocity; + } + + @override + void acceptGesture(int pointer) { + if (_state == _GestureState.possible) { + _state = _GestureState.started; + + // Start drag for this pointer + _startDragForPointer(pointer); + + // Start scale gesture if we have 2+ pointers + if (_pointerLocations.length >= 2 && !_scaleGestureActive) { + _scaleGestureActive = true; + _dispatchOnScaleStartCallback(); + } + + if (dragStartBehavior == DragStartBehavior.start) { + _initialFocalPoint = _currentFocalPoint!; + _initialSpan = _currentSpan; + _initialLine = _currentLine; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + } + } + } + + @override + void rejectGesture(int pointer) { + final Drag? drag = _activeDrags[pointer]; + if (drag != null) { + drag.cancel(); + _activeDrags.remove(pointer); + } + + _pointerLocations.remove(pointer); + _initialPointerLocations.remove(pointer); + _lastPointerPositions.remove(pointer); + _pointerQueue.remove(pointer); + stopTrackingPointer(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) { + switch (_state) { + case _GestureState.possible: + resolve(GestureDisposition.rejected); + case _GestureState.ready: + assert(false); + case _GestureState.accepted: + break; + case _GestureState.started: + assert(false); + } + _state = _GestureState.ready; + _scaleGestureActive = false; + } + + @override + void dispose() { + _velocityTrackers.clear(); + for (final Drag? drag in _activeDrags.values) { + drag?.cancel(); + } + _activeDrags.clear(); + super.dispose(); + } + + @override + String get debugDescription => 'multi-drag-scale'; +} + +enum _GestureState { + ready, + possible, + accepted, + started, +} + +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; +} \ No newline at end of file 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..62c5912e7f3 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 @@ -6,6 +6,7 @@ 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'; 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..93807ba38cf --- /dev/null +++ b/packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart @@ -0,0 +1,190 @@ +import 'dart:async'; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/src/events/flame_drag_adapter.dart'; +import 'package:flame/src/events/tagged_component.dart'; +import 'package:flame/src/game/flame_game.dart'; +import 'package:flame/src/game/game_render_box.dart'; +import 'package:flutter/gestures.dart'; +import 'package:meta/meta.dart'; + +class MultiDragScaleDispatcherKey implements ComponentKey { + const MultiDragScaleDispatcherKey(); + + @override + int get hashCode => 91604879; // '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 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; + + /// 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); + _dragStartController.add(event); + } + + @internal + @override + void handleDragUpdate(int pointerId, DragUpdateDetails details) { + final event = DragUpdateEvent(pointerId, game, details); + onDragUpdate(event); + _dragUpdateController.add(event); + } + + @internal + @override + void handleDragEnd(int pointerId, DragEndDetails details) { + final event = DragEndEvent(pointerId, details); + onDragEnd(event); + _dragEndController.add(event); + } + + @internal + @override + void handleDragCancel(int pointerId) { + final event = DragCancelEvent(pointerId); + onDragCancel(event); + _dragCancelController.add(event); + } + + //#endregion + + @override + void onMount() { + game.gestureDetectors.add( + ImmediateMultiDragGestureRecognizer.new, + (ImmediateMultiDragGestureRecognizer instance) { + instance.onStart = (Offset point) => FlameDragAdapter(this, point); + }, + ); + } + + @override + void onRemove() { + game.gestureDetectors.remove(); + game.unregisterKey(const MultiDragDispatcherKey()); + _dragUpdateController.close(); + _dragCancelController.close(); + _dragStartController.close(); + _dragEndController.close(); + } + + @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..e69de29bb2d From 77df27a813ca6c307024861a9c807220acf0ec62 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 23 Nov 2025 01:10:03 +0100 Subject: [PATCH 27/87] add scale drag --- packages/flame/lib/events.dart | 3 + .../scale_drag_callbacks.dart | 61 +++ .../scale_drag_dispatcher.dart | 129 ++++- .../events/multi_drag_scale_recognizer.dart | 510 ++++++++++++++++++ 4 files changed, 677 insertions(+), 26 deletions(-) create mode 100644 packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 8c1727ad370..4c44d4955ca 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -5,6 +5,7 @@ export 'src/events/component_mixins/hover_callbacks.dart' show HoverCallbacks; export 'src/events/component_mixins/pointer_move_callbacks.dart' show PointerMoveCallbacks; export 'src/events/component_mixins/scale_callbacks.dart' show ScaleCallbacks; +export 'src/events/component_mixins/scale_drag_callbacks.dart' show ScaleDragCallbacks; export 'src/events/component_mixins/secondary_tap_callbacks.dart' show SecondaryTapCallbacks; export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks; @@ -12,6 +13,8 @@ export 'src/events/flame_game_mixins/double_tap_dispatcher.dart' show DoubleTapDispatcher, DoubleTapDispatcherKey; export 'src/events/flame_game_mixins/multi_drag_dispatcher.dart' show MultiDragDispatcher, MultiDragDispatcherKey; +export 'src/events/flame_game_mixins/scale_drag_dispatcher.dart' + show MultiDragScaleDispatcher, MultiDragScaleDispatcherKey; export 'src/events/flame_game_mixins/multi_tap_dispatcher.dart' show MultiTapDispatcher, MultiTapDispatcherKey; export 'src/events/flame_game_mixins/pointer_move_dispatcher.dart' diff --git a/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart b/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart new file mode 100644 index 00000000000..e8165b421bd --- /dev/null +++ b/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart @@ -0,0 +1,61 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_drag_dispatcher.dart'; +import 'package:meta/meta.dart'; + +mixin ScaleDragCallbacks on Component { + bool _isScaling = false; + bool _isDragged = false; + + /// Returns true while the component is being scaled. + bool get isScaling => _isScaling; + + bool get isDragged => _isDragged; + + @mustCallSuper + void onScaleStart(ScaleStartEvent event) { + _isScaling = true; + } + + void onScaleUpdate(ScaleUpdateEvent event) {} + + @mustCallSuper + void onScaleEnd(ScaleEndEvent event) { + _isScaling = false; + } + + @mustCallSuper + void onDragStart(DragStartEvent event) { + _isDragged = true; + } + + /// The user has moved the pointer that initiated the drag gesture. + /// + /// This event will be delivered to the component(s) that captured the initial + /// [onDragStart], even if the point of touch moves outside of the boundaries + /// of the component. In the latter case `event.localPosition` will contain a + /// NaN point. + void onDragUpdate(DragUpdateEvent event) {} + + /// The drag event has ended. + /// + /// This event will be delivered to the component(s) that captured the initial + /// [onDragStart], even if the point of touch moves outside of the boundaries + /// of the component. + @mustCallSuper + void onDragEnd(DragEndEvent event) { + _isDragged = false; + } + + @override + @mustCallSuper + void onMount() { + super.onMount(); + final game = findRootGame()!; + if (game.findByKey(const MultiDragScaleDispatcherKey()) == null) { + final dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + } + } +} 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 index 93807ba38cf..6fb81e5dc2f 100644 --- 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 @@ -3,17 +3,20 @@ import 'dart:async'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/src/events/flame_drag_adapter.dart'; +import 'package:flame/src/events/interfaces/scale_listener.dart'; +import 'package:flame/src/events/multi_drag_scale_recognizer.dart'; import 'package:flame/src/events/tagged_component.dart'; import 'package:flame/src/game/flame_game.dart'; import 'package:flame/src/game/game_render_box.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; class MultiDragScaleDispatcherKey implements ComponentKey { const MultiDragScaleDispatcherKey(); @override - int get hashCode => 91604879; // 'MultiDragDispatcherKey' as hashCode + int get hashCode => 91604875; // 'MultiDragDispatcherKey' as hashCode @override bool operator ==(Object other) => @@ -24,9 +27,9 @@ class MultiDragScaleDispatcherKey implements ComponentKey { /// [DragCallbacks] components in the component tree. It will be attached to /// the [FlameGame] instance automatically whenever any [DragCallbacks] /// components are mounted into the component tree. -class MultiDragDispatcher extends Component implements MultiDragListener { +class MultiDragScaleDispatcher extends Component implements MultiDragListener, ScaleListener { /// The record of all components currently being touched. - final Set> _records = {}; + final Set> _records = {}; final _dragUpdateController = StreamController.broadcast( sync: true, @@ -66,9 +69,10 @@ class MultiDragDispatcher extends Component implements MultiDragListener { /// that may occur simultaneously. @mustCallSuper void onDragStart(DragStartEvent event) { + debugPrint("dispatcher on drag start"); event.deliverAtPoint( rootComponent: game, - eventHandler: (DragCallbacks component) { + eventHandler: (ScaleDragCallbacks component) { _records.add(TaggedComponent(event.pointerId, component)); component.onDragStart(event); }, @@ -83,11 +87,11 @@ class MultiDragDispatcher extends Component implements MultiDragListener { /// delivered, however its `event.localPosition` property will contain NaNs. @mustCallSuper void onDragUpdate(DragUpdateEvent event) { - final updated = >{}; + final updated = >{}; event.deliverAtPoint( rootComponent: game, deliverToAll: true, - eventHandler: (DragCallbacks component) { + eventHandler: (ScaleDragCallbacks component) { final record = TaggedComponent(event.pointerId, component); if (_records.contains(record)) { component.onDragUpdate(event); @@ -118,17 +122,6 @@ class MultiDragDispatcher extends Component implements MultiDragListener { }); } - @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 @@ -155,30 +148,109 @@ class MultiDragDispatcher extends Component implements MultiDragListener { _dragEndController.add(event); } + final Set> _scaleRecords = {}; + + /// Called when the user starts a scale gesture. + @mustCallSuper + void onScaleStart(ScaleStartEvent event) { + event.deliverAtPoint( + rootComponent: game, + eventHandler: (ScaleDragCallbacks 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: (ScaleDragCallbacks 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 handleDragCancel(int pointerId) { - final event = DragCancelEvent(pointerId); - onDragCancel(event); - _dragCancelController.add(event); + 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)); + }*/ } + @internal + @override + void handleScaleEnd(ScaleEndDetails details) { + onScaleEnd(ScaleEndEvent(0, details)); + } + + //#endregion @override void onMount() { game.gestureDetectors.add( - ImmediateMultiDragGestureRecognizer.new, - (ImmediateMultiDragGestureRecognizer instance) { - instance.onStart = (Offset point) => FlameDragAdapter(this, point); + MultiDragScaleGestureRecognizer.new, + (MultiDragScaleGestureRecognizer instance) { + instance.onDragStart = (Offset point) => FlameDragAdapter(this, point); + instance.onScaleStart = handleScaleStart; + instance.onScaleUpdate = handleScaleUpdate; + instance.onScaleEnd = handleScaleEnd; }, ); } @override void onRemove() { - game.gestureDetectors.remove(); - game.unregisterKey(const MultiDragDispatcherKey()); + game.gestureDetectors.remove(); + game.unregisterKey(const MultiDragScaleDispatcherKey()); _dragUpdateController.close(); _dragCancelController.close(); _dragStartController.close(); @@ -187,4 +259,9 @@ class MultiDragDispatcher extends Component implements MultiDragListener { @override GameRenderBox get renderBox => game.renderBox; + + @override + void handleDragCancel(int pointerId) { + // TODO: implement handleDragCancel + } } diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index e69de29bb2d..60d1b9a3325 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -0,0 +1,510 @@ +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 individually (like ImmediateMultiDragGestureRecognizer) +/// while also tracking the overall scale gesture (like ScaleGestureRecognizer). +/// When 2+ pointers are down, both drag callbacks (per pointer) and scale callbacks fire. +class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { + /// Create a gesture recognizer for tracking multi-drag and scale gestures. + MultiDragScaleGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + super.allowedButtonsFilter, + this.dragStartBehavior = DragStartBehavior.down, + this.scaleThreshold = 1.05, + }); + + /// 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? onDragStart; + + /// 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; + + _GestureState _state = _GestureState.ready; + bool _scaleGestureActive = false; + + final Map _pointerLocations = {}; + final Map _initialPointerLocations = {}; + final List _pointerQueue = []; + final Map _velocityTrackers = {}; + final Map _activeDrags = {}; + final Map _lastPointerPositions = {}; + + // Scale-specific fields + late Offset _initialFocalPoint; + Offset? _currentFocalPoint; + late double _initialSpan; + late double _currentSpan; + late double _initialHorizontalSpan; + late double _currentHorizontalSpan; + late double _initialVerticalSpan; + late double _currentVerticalSpan; + late Offset _localFocalPoint; + _LineBetweenPointers? _initialLine; + _LineBetweenPointers? _currentLine; + Matrix4? _lastTransform; + late Offset _delta; + VelocityTracker? _scaleVelocityTracker; + Duration? _initialScaleEventTimestamp; + + int get pointerCount => _pointerLocations.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) { + super.addAllowedPointer(event); + _velocityTrackers[event.pointer] = VelocityTracker.withKind(event.kind); + _initialPointerLocations[event.pointer] = event.position; + _lastPointerPositions[event.pointer] = event.position; + + if (_state == _GestureState.ready) { + _state = _GestureState.possible; + _initialSpan = 0.0; + _currentSpan = 0.0; + _initialHorizontalSpan = 0.0; + _currentHorizontalSpan = 0.0; + _initialVerticalSpan = 0.0; + _currentVerticalSpan = 0.0; + } + } + + @override + void handleEvent(PointerEvent event) { + assert(_state != _GestureState.ready); + bool didChangeConfiguration = false; + bool shouldStartIfAccepted = false; + + if (event is PointerMoveEvent) { + final VelocityTracker tracker = _velocityTrackers[event.pointer]!; + if (!event.synthesized) { + tracker.addPosition(event.timeStamp, event.position); + } + + final Offset lastPosition = _lastPointerPositions[event.pointer]!; + final Offset delta = event.position - lastPosition; + _lastPointerPositions[event.pointer] = event.position; + + // Update individual drag + final Drag? drag = _activeDrags[event.pointer]; + if (drag != null) { + drag.update(DragUpdateDetails( + globalPosition: event.position, + delta: delta, + sourceTimeStamp: event.timeStamp, + localPosition: PointerEvent.transformPosition(event.transform, event.position), + )); + } + + _pointerLocations[event.pointer] = event.position; + shouldStartIfAccepted = true; + _lastTransform = event.transform; + } else if (event is PointerDownEvent) { + _pointerLocations[event.pointer] = event.position; + _pointerQueue.add(event.pointer); + didChangeConfiguration = true; + shouldStartIfAccepted = true; + _lastTransform = event.transform; + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + // End individual drag + final Drag? drag = _activeDrags[event.pointer]; + if (drag != null) { + final VelocityTracker tracker = _velocityTrackers[event.pointer]!; + if (event is PointerUpEvent) { + drag.end(DragEndDetails( + velocity: tracker.getVelocity(), + )); + } else { + drag.cancel(); + } + _activeDrags.remove(event.pointer); + } + + _pointerLocations.remove(event.pointer); + _initialPointerLocations.remove(event.pointer); + _lastPointerPositions.remove(event.pointer); + _pointerQueue.remove(event.pointer); + didChangeConfiguration = true; + _lastTransform = event.transform; + } + + _updateLines(); + _update(); + + if (!didChangeConfiguration || _reconfigure(event.pointer)) { + _advanceStateMachine(shouldStartIfAccepted, event); + } + stopTrackingIfPointerNoLongerDown(event); + } + + void _update() { + final Offset? previousFocalPoint = _currentFocalPoint; + + // Compute the focal point + Offset focalPoint = Offset.zero; + for (final int pointer in _pointerLocations.keys) { + focalPoint += _pointerLocations[pointer]!; + } + _currentFocalPoint = _pointerLocations.isEmpty + ? Offset.zero + : focalPoint / _pointerLocations.length.toDouble(); + + if (previousFocalPoint == null) { + _localFocalPoint = PointerEvent.transformPosition( + _lastTransform, _currentFocalPoint!); + _delta = Offset.zero; + } else { + final Offset localPreviousFocalPoint = _localFocalPoint; + _localFocalPoint = PointerEvent.transformPosition( + _lastTransform, _currentFocalPoint!); + _delta = _localFocalPoint - localPreviousFocalPoint; + } + + final int count = _pointerLocations.keys.length; + Offset pointerFocalPoint = Offset.zero; + for (final int pointer in _pointerLocations.keys) { + pointerFocalPoint += _pointerLocations[pointer]!; + } + if (count > 0) { + pointerFocalPoint = pointerFocalPoint / count.toDouble(); + } + + // Calculate span + double totalDeviation = 0.0; + double totalHorizontalDeviation = 0.0; + double totalVerticalDeviation = 0.0; + for (final int pointer in _pointerLocations.keys) { + totalDeviation += (pointerFocalPoint - _pointerLocations[pointer]!).distance; + totalHorizontalDeviation += + (pointerFocalPoint.dx - _pointerLocations[pointer]!.dx).abs(); + totalVerticalDeviation += + (pointerFocalPoint.dy - _pointerLocations[pointer]!.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 int count = _pointerLocations.keys.length; + assert(_pointerQueue.length >= count); + + if (count < 2) { + _initialLine = _currentLine; + } else if (_initialLine != null && + _initialLine!.pointerStartId == _pointerQueue[0] && + _initialLine!.pointerEndId == _pointerQueue[1]) { + _currentLine = _LineBetweenPointers( + pointerStartId: _pointerQueue[0], + pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, + pointerEndId: _pointerQueue[1], + pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + ); + } else { + _initialLine = _LineBetweenPointers( + pointerStartId: _pointerQueue[0], + pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, + pointerEndId: _pointerQueue[1], + pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + ); + _currentLine = _initialLine; + } + } + + bool _reconfigure(int pointer) { + _initialFocalPoint = _currentFocalPoint!; + _initialSpan = _currentSpan; + _initialLine = _currentLine; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + + if (_state == _GestureState.started && _scaleGestureActive) { + // Check if we should end the scale gesture + if (_pointerLocations.length < 2) { + if (onScaleEnd != null) { + final VelocityTracker tracker = _velocityTrackers[pointer]!; + Velocity velocity = tracker.getVelocity(); + + if (_isFlingGesture(velocity)) { + final Offset pixelsPerSecond = velocity.pixelsPerSecond; + if (pixelsPerSecond.distanceSquared > + kMaxFlingVelocity * kMaxFlingVelocity) { + velocity = Velocity( + pixelsPerSecond: + (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity, + ); + } + invokeCallback('onScaleEnd', () => onScaleEnd!( + ScaleEndDetails( + velocity: velocity, + scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, + pointerCount: pointerCount, + ), + )); + } else { + invokeCallback('onScaleEnd', () => onScaleEnd!( + ScaleEndDetails( + scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, + pointerCount: pointerCount, + ), + )); + } + } + _scaleGestureActive = false; + _state = _GestureState.accepted; + _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); + return false; + } + } + + _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); + return true; + } + + void _advanceStateMachine(bool shouldStartIfAccepted, PointerEvent event) { + if (_state == _GestureState.ready) { + _state = _GestureState.possible; + } + + if (_state == _GestureState.possible) { + // Check if we should start accepting gestures + bool shouldAccept = false; + + if (_pointerLocations.length >= 2) { + final double spanDelta = (_currentSpan - _initialSpan).abs(); + final double scaleFactor = _pointerScaleFactor; + + if (spanDelta > computeScaleSlop(event.kind) || + math.max(scaleFactor, 1.0 / scaleFactor) > scaleThreshold) { + shouldAccept = true; + } + } else if (_pointerLocations.length == 1) { + final int pointer = _pointerLocations.keys.first; + final Offset initialPosition = _initialPointerLocations[pointer]!; + final Offset currentPosition = _pointerLocations[pointer]!; + final double distance = (currentPosition - initialPosition).distance; + + if (distance > computePanSlop(event.kind, gestureSettings)) { + shouldAccept = true; + } + } + + if (shouldAccept) { + resolve(GestureDisposition.accepted); + } + } else if (_state.index >= _GestureState.accepted.index) { + resolve(GestureDisposition.accepted); + } + + if (_state == _GestureState.accepted && shouldStartIfAccepted) { + _state = _GestureState.started; + + // Start individual drags for all pointers that don't have one yet + for (final int pointer in _pointerLocations.keys) { + if (!_activeDrags.containsKey(pointer)) { + _startDragForPointer(pointer); + } + } + + // Start scale gesture if we have 2+ pointers + if (_pointerLocations.length >= 2 && !_scaleGestureActive) { + _scaleGestureActive = true; + _initialScaleEventTimestamp = event.timeStamp; + _dispatchOnScaleStartCallback(); + } + } + + if (_state == _GestureState.started) { + // Start scale gesture if we just got a second pointer + if (!_scaleGestureActive && _pointerLocations.length >= 2) { + _scaleGestureActive = true; + _initialScaleEventTimestamp = event.timeStamp; + _dispatchOnScaleStartCallback(); + } + + // Update scale gesture if active and we still have 2+ pointers + if (_scaleGestureActive && _pointerLocations.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 _startDragForPointer(int pointer) { + if (onDragStart != null) { + final Offset initialPosition = _initialPointerLocations[pointer]!; + final Drag? drag = invokeCallback('onDragStart', () { + return onDragStart!(initialPosition); + }); + _activeDrags[pointer] = drag; + } + } + + void _dispatchOnScaleStartCallback() { + if (onScaleStart != null) { + invokeCallback('onScaleStart', () { + onScaleStart!(ScaleStartDetails( + focalPoint: _currentFocalPoint!, + localFocalPoint: _localFocalPoint, + pointerCount: pointerCount, + sourceTimeStamp: _initialScaleEventTimestamp, + )); + }); + } + } + + double _computeRotationFactor() { + double factor = 0.0; + if (_initialLine != null && _currentLine != null) { + final double fx = _initialLine!.pointerStartLocation.dx; + final double fy = _initialLine!.pointerStartLocation.dy; + final double sx = _initialLine!.pointerEndLocation.dx; + final double sy = _initialLine!.pointerEndLocation.dy; + + final double nfx = _currentLine!.pointerStartLocation.dx; + final double nfy = _currentLine!.pointerStartLocation.dy; + final double nsx = _currentLine!.pointerEndLocation.dx; + final double nsy = _currentLine!.pointerEndLocation.dy; + + final double angle1 = math.atan2(fy - sy, fx - sx); + final double angle2 = math.atan2(nfy - nsy, nfx - nsx); + + factor = angle2 - angle1; + } + return factor; + } + + bool _isFlingGesture(Velocity velocity) { + final double speedSquared = velocity.pixelsPerSecond.distanceSquared; + return speedSquared > kMinFlingVelocity * kMinFlingVelocity; + } + + @override + void acceptGesture(int pointer) { + if (_state == _GestureState.possible) { + _state = _GestureState.started; + + // Start drag for this pointer + _startDragForPointer(pointer); + + // Start scale gesture if we have 2+ pointers + if (_pointerLocations.length >= 2 && !_scaleGestureActive) { + _scaleGestureActive = true; + _dispatchOnScaleStartCallback(); + } + + if (dragStartBehavior == DragStartBehavior.start) { + _initialFocalPoint = _currentFocalPoint!; + _initialSpan = _currentSpan; + _initialLine = _currentLine; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + } + } + } + + @override + void rejectGesture(int pointer) { + final Drag? drag = _activeDrags[pointer]; + if (drag != null) { + drag.cancel(); + _activeDrags.remove(pointer); + } + + _pointerLocations.remove(pointer); + _initialPointerLocations.remove(pointer); + _lastPointerPositions.remove(pointer); + _pointerQueue.remove(pointer); + stopTrackingPointer(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) { + switch (_state) { + case _GestureState.possible: + resolve(GestureDisposition.rejected); + case _GestureState.ready: + assert(false); // Should never happen + case _GestureState.accepted: + case _GestureState.started: + // Valid states - gesture was active and all pointers lifted + break; + } + _state = _GestureState.ready; + _scaleGestureActive = false; + } + + @override + void dispose() { + _velocityTrackers.clear(); + for (final Drag? drag in _activeDrags.values) { + drag?.cancel(); + } + _activeDrags.clear(); + super.dispose(); + } + + @override + String get debugDescription => 'multi-drag-scale'; +} + +enum _GestureState { + ready, + possible, + accepted, + started, +} + +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; +} \ No newline at end of file From bd6bd89757cda837b74996615aba0094ee05974a Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 23 Nov 2025 16:56:40 +0100 Subject: [PATCH 28/87] make gesture more than one sequence --- .../scale_drag_dispatcher.dart | 2 +- .../events/multi_drag_scale_recognizer.dart | 590 ++++++++---------- 2 files changed, 260 insertions(+), 332 deletions(-) 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 index 6fb81e5dc2f..516bb95a19c 100644 --- 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 @@ -239,7 +239,7 @@ class MultiDragScaleDispatcher extends Component implements MultiDragListener, S game.gestureDetectors.add( MultiDragScaleGestureRecognizer.new, (MultiDragScaleGestureRecognizer instance) { - instance.onDragStart = (Offset point) => FlameDragAdapter(this, point); + instance.onStart = (Offset point) => FlameDragAdapter(this, point); instance.onScaleStart = handleScaleStart; instance.onScaleUpdate = handleScaleUpdate; instance.onScaleEnd = handleScaleEnd; diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 60d1b9a3325..3bb4aaf7293 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -1,22 +1,25 @@ import 'dart:math' as math; - import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; /// A gesture recognizer that can recognize both individual pointer drags /// and scale gestures simultaneously. /// -/// This recognizer tracks each pointer individually (like ImmediateMultiDragGestureRecognizer) +/// This recognizer tracks each pointer independently (like ImmediateMultiDragGestureRecognizer) /// while also tracking the overall scale gesture (like ScaleGestureRecognizer). -/// When 2+ pointers are down, both drag callbacks (per pointer) and scale callbacks fire. -class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { +/// 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, - super.allowedButtonsFilter, + 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; @@ -27,7 +30,7 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { /// Called when a pointer starts dragging. One callback per pointer. /// Return a Drag object to receive updates for this specific pointer. - GestureMultiDragStartCallback? onDragStart; + GestureMultiDragStartCallback? onStart; /// Called when a scale gesture starts (when 2+ pointers are active). GestureScaleStartCallback? onScaleStart; @@ -38,34 +41,27 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { /// Called when a scale gesture ends. GestureScaleEndCallback? onScaleEnd; - _GestureState _state = _GestureState.ready; + final Map _pointers = {}; bool _scaleGestureActive = false; - final Map _pointerLocations = {}; - final Map _initialPointerLocations = {}; - final List _pointerQueue = []; - final Map _velocityTrackers = {}; - final Map _activeDrags = {}; - final Map _lastPointerPositions = {}; - // Scale-specific fields - late Offset _initialFocalPoint; + Offset? _initialFocalPoint; Offset? _currentFocalPoint; - late double _initialSpan; - late double _currentSpan; - late double _initialHorizontalSpan; - late double _currentHorizontalSpan; - late double _initialVerticalSpan; - late double _currentVerticalSpan; - late Offset _localFocalPoint; + 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; - late Offset _delta; + Offset _delta = Offset.zero; VelocityTracker? _scaleVelocityTracker; Duration? _initialScaleEventTimestamp; - int get pointerCount => _pointerLocations.length; + int get pointerCount => _pointers.length; double get _pointerScaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0; @@ -78,88 +74,127 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { @override void addAllowedPointer(PointerDownEvent event) { - super.addAllowedPointer(event); - _velocityTrackers[event.pointer] = VelocityTracker.withKind(event.kind); - _initialPointerLocations[event.pointer] = event.position; - _lastPointerPositions[event.pointer] = event.position; - - if (_state == _GestureState.ready) { - _state = _GestureState.possible; - _initialSpan = 0.0; - _currentSpan = 0.0; - _initialHorizontalSpan = 0.0; - _currentHorizontalSpan = 0.0; - _initialVerticalSpan = 0.0; - _currentVerticalSpan = 0.0; + assert(!_pointers.containsKey(event.pointer)); + final _DragPointerState state = _DragPointerState( + recognizer: this, + event: event, + ); + _pointers[event.pointer] = state; + GestureBinding.instance.pointerRouter.addRoute(event.pointer, _handleEvent); + state._setArenaEntry(GestureBinding.instance.gestureArena.add(event.pointer, this)); + } + + void _handleEvent(PointerEvent event) { + assert(_pointers.containsKey(event.pointer)); + final _DragPointerState 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); + } else if (event is PointerCancelEvent) { + assert(event.delta == Offset.zero); + state._cancel(event); + _removeState(event.pointer); + } else if (event is! PointerDownEvent) { + assert(false); } } - @override - void handleEvent(PointerEvent event) { - assert(_state != _GestureState.ready); - bool didChangeConfiguration = false; - bool shouldStartIfAccepted = false; + void _updateScale(PointerEvent event) { + _lastTransform = event.transform; + + // Update all pointer positions for scale calculation + _update(); + _updateLines(); - if (event is PointerMoveEvent) { - final VelocityTracker tracker = _velocityTrackers[event.pointer]!; - if (!event.synthesized) { - tracker.addPosition(event.timeStamp, event.position); - } - - final Offset lastPosition = _lastPointerPositions[event.pointer]!; - final Offset delta = event.position - lastPosition; - _lastPointerPositions[event.pointer] = event.position; + // 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); - // Update individual drag - final Drag? drag = _activeDrags[event.pointer]; - if (drag != null) { - drag.update(DragUpdateDetails( - globalPosition: event.position, - delta: delta, - sourceTimeStamp: event.timeStamp, - localPosition: PointerEvent.transformPosition(event.transform, event.position), - )); + 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)); - _pointerLocations[event.pointer] = event.position; - shouldStartIfAccepted = true; - _lastTransform = event.transform; - } else if (event is PointerDownEvent) { - _pointerLocations[event.pointer] = event.position; - _pointerQueue.add(event.pointer); - didChangeConfiguration = true; - shouldStartIfAccepted = true; - _lastTransform = event.transform; - } else if (event is PointerUpEvent || event is PointerCancelEvent) { - // End individual drag - final Drag? drag = _activeDrags[event.pointer]; - if (drag != null) { - final VelocityTracker tracker = _velocityTrackers[event.pointer]!; - if (event is PointerUpEvent) { - drag.end(DragEndDetails( - velocity: tracker.getVelocity(), + 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, )); + }); + } + } + + // End scale gesture if we drop below 2 pointers + if (_scaleGestureActive && _pointers.length < 2) { + if (onScaleEnd != null) { + final Velocity velocity = _scaleVelocityTracker?.getVelocity() ?? Velocity.zero; + + if (_isFlingGesture(velocity)) { + final Offset pixelsPerSecond = velocity.pixelsPerSecond; + if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) { + final Velocity 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 { - drag.cancel(); + invokeCallback('onScaleEnd', () => onScaleEnd!( + ScaleEndDetails( + scaleVelocity: velocity.pixelsPerSecond.dx, + pointerCount: pointerCount, + ), + )); } - _activeDrags.remove(event.pointer); } - _pointerLocations.remove(event.pointer); - _initialPointerLocations.remove(event.pointer); - _lastPointerPositions.remove(event.pointer); - _pointerQueue.remove(event.pointer); - didChangeConfiguration = true; - _lastTransform = event.transform; - } - - _updateLines(); - _update(); - - if (!didChangeConfiguration || _reconfigure(event.pointer)) { - _advanceStateMachine(shouldStartIfAccepted, event); + _scaleGestureActive = false; + _scaleVelocityTracker = null; } - stopTrackingIfPointerNoLongerDown(event); } void _update() { @@ -167,28 +202,26 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { // Compute the focal point Offset focalPoint = Offset.zero; - for (final int pointer in _pointerLocations.keys) { - focalPoint += _pointerLocations[pointer]!; + for (final _DragPointerState state in _pointers.values) { + focalPoint += state.currentPosition; } - _currentFocalPoint = _pointerLocations.isEmpty + _currentFocalPoint = _pointers.isEmpty ? Offset.zero - : focalPoint / _pointerLocations.length.toDouble(); + : focalPoint / _pointers.length.toDouble(); if (previousFocalPoint == null) { - _localFocalPoint = PointerEvent.transformPosition( - _lastTransform, _currentFocalPoint!); + _localFocalPoint = PointerEvent.transformPosition(_lastTransform, _currentFocalPoint!); _delta = Offset.zero; } else { final Offset localPreviousFocalPoint = _localFocalPoint; - _localFocalPoint = PointerEvent.transformPosition( - _lastTransform, _currentFocalPoint!); + _localFocalPoint = PointerEvent.transformPosition(_lastTransform, _currentFocalPoint!); _delta = _localFocalPoint - localPreviousFocalPoint; } - final int count = _pointerLocations.keys.length; + final int count = _pointers.length; Offset pointerFocalPoint = Offset.zero; - for (final int pointer in _pointerLocations.keys) { - pointerFocalPoint += _pointerLocations[pointer]!; + for (final _DragPointerState state in _pointers.values) { + pointerFocalPoint += state.currentPosition; } if (count > 0) { pointerFocalPoint = pointerFocalPoint / count.toDouble(); @@ -198,12 +231,10 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { double totalDeviation = 0.0; double totalHorizontalDeviation = 0.0; double totalVerticalDeviation = 0.0; - for (final int pointer in _pointerLocations.keys) { - totalDeviation += (pointerFocalPoint - _pointerLocations[pointer]!).distance; - totalHorizontalDeviation += - (pointerFocalPoint.dx - _pointerLocations[pointer]!.dx).abs(); - totalVerticalDeviation += - (pointerFocalPoint.dy - _pointerLocations[pointer]!.dy).abs(); + for (final _DragPointerState 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; @@ -211,188 +242,31 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { } void _updateLines() { - final int count = _pointerLocations.keys.length; - assert(_pointerQueue.length >= count); + final int count = _pointers.length; + final List pointerIds = _pointers.keys.toList(); if (count < 2) { _initialLine = _currentLine; } else if (_initialLine != null && - _initialLine!.pointerStartId == _pointerQueue[0] && - _initialLine!.pointerEndId == _pointerQueue[1]) { + _initialLine!.pointerStartId == pointerIds[0] && + _initialLine!.pointerEndId == pointerIds[1]) { _currentLine = _LineBetweenPointers( - pointerStartId: _pointerQueue[0], - pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, - pointerEndId: _pointerQueue[1], - pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + pointerStartId: pointerIds[0], + pointerStartLocation: _pointers[pointerIds[0]]!.currentPosition, + pointerEndId: pointerIds[1], + pointerEndLocation: _pointers[pointerIds[1]]!.currentPosition, ); } else { _initialLine = _LineBetweenPointers( - pointerStartId: _pointerQueue[0], - pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, - pointerEndId: _pointerQueue[1], - pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + pointerStartId: pointerIds[0], + pointerStartLocation: _pointers[pointerIds[0]]!.currentPosition, + pointerEndId: pointerIds[1], + pointerEndLocation: _pointers[pointerIds[1]]!.currentPosition, ); _currentLine = _initialLine; } } - bool _reconfigure(int pointer) { - _initialFocalPoint = _currentFocalPoint!; - _initialSpan = _currentSpan; - _initialLine = _currentLine; - _initialHorizontalSpan = _currentHorizontalSpan; - _initialVerticalSpan = _currentVerticalSpan; - - if (_state == _GestureState.started && _scaleGestureActive) { - // Check if we should end the scale gesture - if (_pointerLocations.length < 2) { - if (onScaleEnd != null) { - final VelocityTracker tracker = _velocityTrackers[pointer]!; - Velocity velocity = tracker.getVelocity(); - - if (_isFlingGesture(velocity)) { - final Offset pixelsPerSecond = velocity.pixelsPerSecond; - if (pixelsPerSecond.distanceSquared > - kMaxFlingVelocity * kMaxFlingVelocity) { - velocity = Velocity( - pixelsPerSecond: - (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity, - ); - } - invokeCallback('onScaleEnd', () => onScaleEnd!( - ScaleEndDetails( - velocity: velocity, - scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, - pointerCount: pointerCount, - ), - )); - } else { - invokeCallback('onScaleEnd', () => onScaleEnd!( - ScaleEndDetails( - scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, - pointerCount: pointerCount, - ), - )); - } - } - _scaleGestureActive = false; - _state = _GestureState.accepted; - _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); - return false; - } - } - - _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); - return true; - } - - void _advanceStateMachine(bool shouldStartIfAccepted, PointerEvent event) { - if (_state == _GestureState.ready) { - _state = _GestureState.possible; - } - - if (_state == _GestureState.possible) { - // Check if we should start accepting gestures - bool shouldAccept = false; - - if (_pointerLocations.length >= 2) { - final double spanDelta = (_currentSpan - _initialSpan).abs(); - final double scaleFactor = _pointerScaleFactor; - - if (spanDelta > computeScaleSlop(event.kind) || - math.max(scaleFactor, 1.0 / scaleFactor) > scaleThreshold) { - shouldAccept = true; - } - } else if (_pointerLocations.length == 1) { - final int pointer = _pointerLocations.keys.first; - final Offset initialPosition = _initialPointerLocations[pointer]!; - final Offset currentPosition = _pointerLocations[pointer]!; - final double distance = (currentPosition - initialPosition).distance; - - if (distance > computePanSlop(event.kind, gestureSettings)) { - shouldAccept = true; - } - } - - if (shouldAccept) { - resolve(GestureDisposition.accepted); - } - } else if (_state.index >= _GestureState.accepted.index) { - resolve(GestureDisposition.accepted); - } - - if (_state == _GestureState.accepted && shouldStartIfAccepted) { - _state = _GestureState.started; - - // Start individual drags for all pointers that don't have one yet - for (final int pointer in _pointerLocations.keys) { - if (!_activeDrags.containsKey(pointer)) { - _startDragForPointer(pointer); - } - } - - // Start scale gesture if we have 2+ pointers - if (_pointerLocations.length >= 2 && !_scaleGestureActive) { - _scaleGestureActive = true; - _initialScaleEventTimestamp = event.timeStamp; - _dispatchOnScaleStartCallback(); - } - } - - if (_state == _GestureState.started) { - // Start scale gesture if we just got a second pointer - if (!_scaleGestureActive && _pointerLocations.length >= 2) { - _scaleGestureActive = true; - _initialScaleEventTimestamp = event.timeStamp; - _dispatchOnScaleStartCallback(); - } - - // Update scale gesture if active and we still have 2+ pointers - if (_scaleGestureActive && _pointerLocations.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 _startDragForPointer(int pointer) { - if (onDragStart != null) { - final Offset initialPosition = _initialPointerLocations[pointer]!; - final Drag? drag = invokeCallback('onDragStart', () { - return onDragStart!(initialPosition); - }); - _activeDrags[pointer] = drag; - } - } - - void _dispatchOnScaleStartCallback() { - if (onScaleStart != null) { - invokeCallback('onScaleStart', () { - onScaleStart!(ScaleStartDetails( - focalPoint: _currentFocalPoint!, - localFocalPoint: _localFocalPoint, - pointerCount: pointerCount, - sourceTimeStamp: _initialScaleEventTimestamp, - )); - }); - } - } - double _computeRotationFactor() { double factor = 0.0; if (_initialLine != null && _currentLine != null) { @@ -419,68 +293,48 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { return speedSquared > kMinFlingVelocity * kMinFlingVelocity; } + Drag? _startDrag(Offset initialPosition, int pointer) { + assert(_pointers.containsKey(pointer)); + Drag? drag; + if (onStart != null) { + drag = invokeCallback('onStart', () => onStart!(initialPosition)); + } + return drag; + } + @override void acceptGesture(int pointer) { - if (_state == _GestureState.possible) { - _state = _GestureState.started; - - // Start drag for this pointer - _startDragForPointer(pointer); - - // Start scale gesture if we have 2+ pointers - if (_pointerLocations.length >= 2 && !_scaleGestureActive) { - _scaleGestureActive = true; - _dispatchOnScaleStartCallback(); - } - - if (dragStartBehavior == DragStartBehavior.start) { - _initialFocalPoint = _currentFocalPoint!; - _initialSpan = _currentSpan; - _initialLine = _currentLine; - _initialHorizontalSpan = _currentHorizontalSpan; - _initialVerticalSpan = _currentVerticalSpan; - } + final _DragPointerState? state = _pointers[pointer]; + if (state == null) { + return; // Already removed } + state._accepted(() => _startDrag(state.initialPosition, pointer)); } @override void rejectGesture(int pointer) { - final Drag? drag = _activeDrags[pointer]; - if (drag != null) { - drag.cancel(); - _activeDrags.remove(pointer); + final _DragPointerState? state = _pointers[pointer]; + if (state != null) { + state._rejected(); + _removeState(pointer); } - - _pointerLocations.remove(pointer); - _initialPointerLocations.remove(pointer); - _lastPointerPositions.remove(pointer); - _pointerQueue.remove(pointer); - stopTrackingPointer(pointer); } - @override - void didStopTrackingLastPointer(int pointer) { - switch (_state) { - case _GestureState.possible: - resolve(GestureDisposition.rejected); - case _GestureState.ready: - assert(false); // Should never happen - case _GestureState.accepted: - case _GestureState.started: - // Valid states - gesture was active and all pointers lifted - break; + void _removeState(int pointer) { + if (!_pointers.containsKey(pointer)) { + return; } - _state = _GestureState.ready; - _scaleGestureActive = false; + GestureBinding.instance.pointerRouter.removeRoute(pointer, _handleEvent); + _pointers.remove(pointer)!._dispose(); } @override void dispose() { - _velocityTrackers.clear(); - for (final Drag? drag in _activeDrags.values) { - drag?.cancel(); + final List pointers = _pointers.keys.toList(); + for (final int pointer in pointers) { + _removeState(pointer); } - _activeDrags.clear(); + assert(_pointers.isEmpty); super.dispose(); } @@ -488,11 +342,85 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { String get debugDescription => 'multi-drag-scale'; } -enum _GestureState { - ready, - possible, - accepted, - started, +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; + Offset? _lastPosition; + late VelocityTracker velocityTracker; + GestureArenaEntry? _arenaEntry; + Drag? _drag; + bool _resolved = false; + + void _setArenaEntry(GestureArenaEntry entry) { + _arenaEntry = entry; + } + + void _move(PointerMoveEvent event) { + if (!event.synthesized) { + velocityTracker.addPosition(event.timeStamp, event.position); + } + + final Offset delta = event.position - currentPosition; + currentPosition = event.position; + + if (_drag != null) { + _drag!.update(DragUpdateDetails( + globalPosition: event.position, + delta: delta, + sourceTimeStamp: event.timeStamp, + localPosition: PointerEvent.transformPosition(event.transform, event.position), + )); + } else if (!_resolved) { + // Check if we should resolve the gesture + final double distance = (currentPosition - initialPosition).distance; + if (distance > computePanSlop(kind, recognizer.gestureSettings)) { + _arenaEntry?.resolve(GestureDisposition.accepted); + } + } + } + + 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) { + _drag = starter(); + _resolved = true; + } + } + + void _rejected() { + _resolved = true; + } + + void _dispose() { + _arenaEntry?.resolve(GestureDisposition.rejected); + _arenaEntry = null; + _drag = null; + } } class _LineBetweenPointers { From d6995a8798fe3a5f684f40bd0ea852884518cfa3 Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 23 Nov 2025 21:58:48 +0100 Subject: [PATCH 29/87] add test --- .../scale_drag_callbacks.dart | 11 +- .../scale_drag_callbacks_test.dart | 639 ++++++++++++++++++ 2 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 packages/flame/test/events/component_mixins/scale_drag_callbacks_test.dart diff --git a/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart b/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart index e8165b421bd..acae1f160fc 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart @@ -1,6 +1,7 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/src/events/flame_game_mixins/scale_drag_dispatcher.dart'; +import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; mixin ScaleDragCallbacks on Component { @@ -14,13 +15,18 @@ mixin ScaleDragCallbacks on Component { @mustCallSuper void onScaleStart(ScaleStartEvent event) { + debugPrint("[ScaleDragCallbacks] on scale start"); _isScaling = true; } - - void onScaleUpdate(ScaleUpdateEvent event) {} + + @mustCallSuper + void onScaleUpdate(ScaleUpdateEvent event) { + debugPrint("[ScaleDragCallbacks] on scale update"); + } @mustCallSuper void onScaleEnd(ScaleEndEvent event) { + debugPrint("[ScaleDragCallbacks] on scale end"); _isScaling = false; } @@ -35,6 +41,7 @@ mixin ScaleDragCallbacks on Component { /// [onDragStart], even if the point of touch moves outside of the boundaries /// of the component. In the latter case `event.localPosition` will contain a /// NaN point. + @mustCallSuper void onDragUpdate(DragUpdateEvent event) {} /// The drag event has ended. 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..943340ddde8 --- /dev/null +++ b/packages/flame/test/events/component_mixins/scale_drag_callbacks_test.dart @@ -0,0 +1,639 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart' hide PointerMoveEvent; +import 'package:flame/game.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/gestures.dart' show PointerAddedEvent, kPrimaryButton; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ScaleCallbacks', () { + testWithFlameGame( + 'make sure ScaleDragCallback components can be added to a FlameGame', + (game) async { + await game.add(_ScaleDragCallbacksComponent()); + await game.ready(); + expect(game.children.toList()[2], isA()); + }, + ); + }); + testWithFlameGame('scale drag event start', (game) async { + final component = _ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + game.add(component); + await game.ready(); + + expect(game.children.whereType().length, 1); + game.firstChild()!.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.containsLocalPoint(Vector2(10, 10)), false); + }); + + testWithFlameGame('scale event start, update and end', (game) async { + final component = _ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.scaleStartEvent, 1); + expect(component.scaleUpdateEvent, 0); + expect(component.scaleEndEvent, 0); + + dispatcher.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)); + + dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + expect(component.scaleEndEvent, equals(1)); + }); + + testWithFlameGame( + 'scale event update not called without onScaleStart', + (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)); + }, + ); + + testWidgets('scale 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 _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)); + }); + + testWidgets( + 'scale 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 _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)); + }, + ); + + testWithGame( + 'make sure the FlameGame can registers Scale Callbacks on itself', + _ScaleDragCallbacksGame.new, + (game) async { + await game.ready(); + expect(game.children.length, equals(3)); + expect(game.children.elementAt(1), isA()); + }, + ); + + testWidgets( + 'scale 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 _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)); + }, + ); + + testWidgets( + 'isScaled 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 _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)); + + // Outside component + await _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)); + }, + ); + group('HasScalableComponents', () { + testWidgets( + 'scale event 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++, + ), + _SimpleScaleDragCallbacksComponent(size: Vector2.all(200)) + ..priority = 10, + ], + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + await _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 event can move outside the component bounds and still fire', + (tester) async { + var nEvents = 0; + const intervals = 50; + final component = _ScaleDragWithCallbacksComponent( + size: Vector2.all(30), + position: Vector2.all(100), + onScaleUpdate: (e) => nEvents++, + ); + 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(nEvents, intervals * 2 + 2); + }, + ); + }); + + testWidgets( + 'scale event scale 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( + '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, + ); + + 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)); + }, + ); +} + +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 _ScaleDragCounter on ScaleDragCallbacks { + 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); + debugPrint("scale end counter"); + expect(event.raw, isNotNull); + event.handled = true; + scaleEndEvent++; + if (_wasScaled != isScaling) { + ++isScaledStateChange; + _wasScaled = isScaling; + } + } + + bool _wasDragged = false; + int dragStartEvent = 0; + int dragUpdateEvent = 0; + int dragEndEvent = 0; + int dragCancelEvent = 0; + int isDraggedStateChange = 0; + + @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; + } + } +} + +class _ScaleDragCallbacksComponent extends PositionComponent + with ScaleDragCallbacks, _ScaleDragCounter {} + +class _ScaleDragCallbacksGame extends FlameGame + with ScaleDragCallbacks, _ScaleDragCounter {} + +class _SimpleScaleDragCallbacksComponent extends PositionComponent + with ScaleDragCallbacks { + _SimpleScaleDragCallbacksComponent({super.size}); +} + + +class _ScaleDragWithCallbacksComponent extends PositionComponent + with ScaleDragCallbacks, _ScaleDragCounter { + _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); + }); + } +} From 9a010d3a17477554d20e327f4ef285b9aa81bbce Mon Sep 17 00:00:00 2001 From: stilnat Date: Sun, 23 Nov 2025 22:00:47 +0100 Subject: [PATCH 30/87] update recognizer --- .../events/multi_drag_scale_recognizer.dart | 89 ++++++++++--------- 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 3bb4aaf7293..c1b7f216e9b 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -154,47 +154,6 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { }); } } - - // End scale gesture if we drop below 2 pointers - if (_scaleGestureActive && _pointers.length < 2) { - if (onScaleEnd != null) { - final Velocity velocity = _scaleVelocityTracker?.getVelocity() ?? Velocity.zero; - - if (_isFlingGesture(velocity)) { - final Offset pixelsPerSecond = velocity.pixelsPerSecond; - if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) { - final Velocity 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 _update() { @@ -324,8 +283,54 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { if (!_pointers.containsKey(pointer)) { return; } + + // Check if we need to end the scale gesture before removing the pointer + final bool hadTwoOrMorePointers = _pointers.length >= 2; + final bool willHaveFewerThanTwo = _pointers.length - 1 < 2; + GestureBinding.instance.pointerRouter.removeRoute(pointer, _handleEvent); _pointers.remove(pointer)!._dispose(); + + // End scale gesture if we just dropped below 2 pointers + if (_scaleGestureActive && hadTwoOrMorePointers && willHaveFewerThanTwo) { + if (onScaleEnd != null) { + final Velocity velocity = _scaleVelocityTracker?.getVelocity() ?? Velocity.zero; + + if (_isFlingGesture(velocity)) { + final Offset pixelsPerSecond = velocity.pixelsPerSecond; + if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) { + final Velocity clampedVelocity = Velocity( + pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity, + ); + invokeCallback('onScaleEnd', () => onScaleEnd!( + ScaleEndDetails( + velocity: clampedVelocity, + scaleVelocity: velocity.pixelsPerSecond.dx, + pointerCount: _pointers.length, + ), + )); + } else { + invokeCallback('onScaleEnd', () => onScaleEnd!( + ScaleEndDetails( + velocity: velocity, + scaleVelocity: velocity.pixelsPerSecond.dx, + pointerCount: _pointers.length, + ), + )); + } + } else { + invokeCallback('onScaleEnd', () => onScaleEnd!( + ScaleEndDetails( + scaleVelocity: velocity.pixelsPerSecond.dx, + pointerCount: _pointers.length, + ), + )); + } + } + + _scaleGestureActive = false; + _scaleVelocityTracker = null; + } } @override @@ -357,7 +362,7 @@ class _DragPointerState { final PointerDeviceKind kind; Offset currentPosition; - Offset? _lastPosition; + late VelocityTracker velocityTracker; GestureArenaEntry? _arenaEntry; Drag? _drag; From f1cd9eb68bb8a789d6c80f175e7148cda6be6e01 Mon Sep 17 00:00:00 2001 From: stilnat Date: Mon, 24 Nov 2025 01:34:42 +0100 Subject: [PATCH 31/87] working recognizer pass all tests --- examples/lib/stories/input/scale_example.dart | 199 ++++++----- packages/flame/lib/events.dart | 3 +- .../scale_drag_callbacks.dart | 6 +- .../scale_drag_dispatcher.dart | 6 +- .../events/multi_drag_scale_recognizer.dart | 310 ++++++++++++------ .../scale_drag_callbacks_test.dart | 19 +- 6 files changed, 332 insertions(+), 211 deletions(-) diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_example.dart index 5b85addbd5f..acae59bed3f 100644 --- a/examples/lib/stories/input/scale_example.dart +++ b/examples/lib/stories/input/scale_example.dart @@ -16,7 +16,7 @@ class MyDrag extends Drag { // This specific finger moved by details.delta print('This finger moved: ${details.delta}'); } - + @override void end(DragEndDetails details) { print('This finger lifted'); @@ -37,7 +37,6 @@ class ScaleExample extends FlameGame { final bool addZoom = false; final bool addCameraRotation = false; - Drag onDragStart(Offset initialPosition) { debugPrint('Drag started'); return MyDrag(); @@ -54,16 +53,15 @@ class ScaleExample extends FlameGame { @override Future onLoad() async { camera.viewfinder.zoom = 1; - (parent! as FlameGame).gestureDetectors.add( - MultiDragScaleGestureRecognizer.new, - (MultiDragScaleGestureRecognizer instance) { - instance - ..onDragStart = onDragStart - ..onScaleUpdate = onScaleUpdate; - }, - ); - - + (parent! as FlameGame).gestureDetectors + .add( + MultiDragScaleGestureRecognizer.new, + (MultiDragScaleGestureRecognizer instance) { + instance + ..onDragStart = onDragStart + ..onScaleUpdate = onScaleUpdate; + }, + ); debugText = TextComponent( text: 'hello', @@ -332,9 +330,9 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { } } -/// A gesture recognizer that can recognize both individual pointer drags +/// A gesture recognizer that can recognize both individual pointer drags /// and scale gestures simultaneously. -/// +/// /// This recognizer tracks each pointer individually (like ImmediateMultiDragGestureRecognizer) /// while also tracking the overall scale gesture (like ScaleGestureRecognizer). /// When 2+ pointers are down, both drag callbacks (per pointer) and scale callbacks fire. @@ -397,14 +395,16 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { int get pointerCount => _pointerLocations.length; - double get _pointerScaleFactor => + double get _pointerScaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0; - double get _pointerHorizontalScaleFactor => - _initialHorizontalSpan > 0.0 ? _currentHorizontalSpan / _initialHorizontalSpan : 1.0; + double get _pointerHorizontalScaleFactor => _initialHorizontalSpan > 0.0 + ? _currentHorizontalSpan / _initialHorizontalSpan + : 1.0; - double get _pointerVerticalScaleFactor => - _initialVerticalSpan > 0.0 ? _currentVerticalSpan / _initialVerticalSpan : 1.0; + double get _pointerVerticalScaleFactor => _initialVerticalSpan > 0.0 + ? _currentVerticalSpan / _initialVerticalSpan + : 1.0; @override void addAllowedPointer(PointerDownEvent event) { @@ -435,23 +435,28 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { if (!event.synthesized) { tracker.addPosition(event.timeStamp, event.position); } - + final Offset lastPosition = _lastPointerPositions[event.pointer]!; final Offset delta = event.position - lastPosition; _lastPointerPositions[event.pointer] = event.position; - + // Update individual drag final Drag? drag = _activeDrags[event.pointer]; if (drag != null) { - drag.update(DragUpdateDetails( - globalPosition: event.position, - delta: delta, - primaryDelta: delta.dy, - sourceTimeStamp: event.timeStamp, - localPosition: PointerEvent.transformPosition(event.transform, event.position), - )); + drag.update( + DragUpdateDetails( + globalPosition: event.position, + delta: delta, + primaryDelta: delta.dy, + sourceTimeStamp: event.timeStamp, + localPosition: PointerEvent.transformPosition( + event.transform, + event.position, + ), + ), + ); } - + _pointerLocations[event.pointer] = event.position; shouldStartIfAccepted = true; _lastTransform = event.transform; @@ -467,16 +472,18 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { if (drag != null) { final VelocityTracker tracker = _velocityTrackers[event.pointer]!; if (event is PointerUpEvent) { - drag.end(DragEndDetails( - velocity: tracker.getVelocity(), - primaryVelocity: tracker.getVelocity().pixelsPerSecond.dy, - )); + drag.end( + DragEndDetails( + velocity: tracker.getVelocity(), + primaryVelocity: tracker.getVelocity().pixelsPerSecond.dy, + ), + ); } else { drag.cancel(); } _activeDrags.remove(event.pointer); } - + _pointerLocations.remove(event.pointer); _initialPointerLocations.remove(event.pointer); _lastPointerPositions.remove(event.pointer); @@ -508,12 +515,16 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { if (previousFocalPoint == null) { _localFocalPoint = PointerEvent.transformPosition( - _lastTransform, _currentFocalPoint!); + _lastTransform, + _currentFocalPoint!, + ); _delta = Offset.zero; } else { final Offset localPreviousFocalPoint = _localFocalPoint; _localFocalPoint = PointerEvent.transformPosition( - _lastTransform, _currentFocalPoint!); + _lastTransform, + _currentFocalPoint!, + ); _delta = _localFocalPoint - localPreviousFocalPoint; } @@ -531,10 +542,11 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { double totalHorizontalDeviation = 0.0; double totalVerticalDeviation = 0.0; for (final int pointer in _pointerLocations.keys) { - totalDeviation += (pointerFocalPoint - _pointerLocations[pointer]!).distance; - totalHorizontalDeviation += + totalDeviation += + (pointerFocalPoint - _pointerLocations[pointer]!).distance; + totalHorizontalDeviation += (pointerFocalPoint.dx - _pointerLocations[pointer]!.dx).abs(); - totalVerticalDeviation += + totalVerticalDeviation += (pointerFocalPoint.dy - _pointerLocations[pointer]!.dy).abs(); } _currentSpan = count > 0 ? totalDeviation / count : 0.0; @@ -581,35 +593,48 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { if (onScaleEnd != null) { final VelocityTracker tracker = _velocityTrackers[pointer]!; Velocity velocity = tracker.getVelocity(); - + if (_isFlingGesture(velocity)) { final Offset pixelsPerSecond = velocity.pixelsPerSecond; - if (pixelsPerSecond.distanceSquared > + if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) { velocity = Velocity( - pixelsPerSecond: - (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity, + pixelsPerSecond: + (pixelsPerSecond / pixelsPerSecond.distance) * + kMaxFlingVelocity, ); } - invokeCallback('onScaleEnd', () => onScaleEnd!( - ScaleEndDetails( - velocity: velocity, - scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, - pointerCount: pointerCount, + invokeCallback( + 'onScaleEnd', + () => onScaleEnd!( + ScaleEndDetails( + velocity: velocity, + scaleVelocity: + _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? + -1, + pointerCount: pointerCount, + ), ), - )); + ); } else { - invokeCallback('onScaleEnd', () => onScaleEnd!( - ScaleEndDetails( - scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, - pointerCount: pointerCount, + invokeCallback( + 'onScaleEnd', + () => onScaleEnd!( + ScaleEndDetails( + scaleVelocity: + _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? + -1, + pointerCount: pointerCount, + ), ), - )); + ); } } _scaleGestureActive = false; _state = _GestureState.accepted; - _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); + _scaleVelocityTracker = VelocityTracker.withKind( + PointerDeviceKind.touch, + ); return false; } } @@ -626,11 +651,11 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { if (_state == _GestureState.possible) { // Check if we should start accepting gestures bool shouldAccept = false; - + if (_pointerLocations.length >= 2) { final double spanDelta = (_currentSpan - _initialSpan).abs(); final double scaleFactor = _pointerScaleFactor; - + if (spanDelta > computeScaleSlop(event.kind) || math.max(scaleFactor, 1.0 / scaleFactor) > scaleThreshold) { shouldAccept = true; @@ -640,12 +665,12 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { final Offset initialPosition = _initialPointerLocations[pointer]!; final Offset currentPosition = _pointerLocations[pointer]!; final double distance = (currentPosition - initialPosition).distance; - + if (distance > computePanSlop(event.kind, gestureSettings)) { shouldAccept = true; } } - + if (shouldAccept) { resolve(GestureDisposition.accepted); } @@ -655,14 +680,14 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { if (_state == _GestureState.accepted && shouldStartIfAccepted) { _state = _GestureState.started; - + // Start individual drags for all pointers that don't have one yet for (final int pointer in _pointerLocations.keys) { if (!_activeDrags.containsKey(pointer)) { _startDragForPointer(pointer); } } - + // Start scale gesture if we have 2+ pointers if (_pointerLocations.length >= 2 && !_scaleGestureActive) { _scaleGestureActive = true; @@ -675,20 +700,24 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { // Update scale gesture if active if (_scaleGestureActive && _pointerLocations.length >= 2) { _scaleVelocityTracker?.addPosition( - event.timeStamp, Offset(_pointerScaleFactor, 0)); + 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, - )); + onScaleUpdate!( + ScaleUpdateDetails( + scale: _pointerScaleFactor, + horizontalScale: _pointerHorizontalScaleFactor, + verticalScale: _pointerVerticalScaleFactor, + focalPoint: _currentFocalPoint!, + localFocalPoint: _localFocalPoint, + rotation: _computeRotationFactor(), + pointerCount: pointerCount, + focalPointDelta: _delta, + sourceTimeStamp: event.timeStamp, + ), + ); }); } } else if (!_scaleGestureActive && _pointerLocations.length >= 2) { @@ -713,12 +742,14 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { void _dispatchOnScaleStartCallback() { if (onScaleStart != null) { invokeCallback('onScaleStart', () { - onScaleStart!(ScaleStartDetails( - focalPoint: _currentFocalPoint!, - localFocalPoint: _localFocalPoint, - pointerCount: pointerCount, - sourceTimeStamp: _initialScaleEventTimestamp, - )); + onScaleStart!( + ScaleStartDetails( + focalPoint: _currentFocalPoint!, + localFocalPoint: _localFocalPoint, + pointerCount: pointerCount, + sourceTimeStamp: _initialScaleEventTimestamp, + ), + ); }); } } @@ -753,16 +784,16 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { void acceptGesture(int pointer) { if (_state == _GestureState.possible) { _state = _GestureState.started; - + // Start drag for this pointer _startDragForPointer(pointer); - + // Start scale gesture if we have 2+ pointers if (_pointerLocations.length >= 2 && !_scaleGestureActive) { _scaleGestureActive = true; _dispatchOnScaleStartCallback(); } - + if (dragStartBehavior == DragStartBehavior.start) { _initialFocalPoint = _currentFocalPoint!; _initialSpan = _currentSpan; @@ -780,7 +811,7 @@ class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { drag.cancel(); _activeDrags.remove(pointer); } - + _pointerLocations.remove(pointer); _initialPointerLocations.remove(pointer); _lastPointerPositions.remove(pointer); @@ -837,4 +868,4 @@ class _LineBetweenPointers { final Offset pointerStartLocation; final int pointerEndId; final Offset pointerEndLocation; -} \ No newline at end of file +} diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 4c44d4955ca..d9811cc5e5d 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -5,7 +5,8 @@ export 'src/events/component_mixins/hover_callbacks.dart' show HoverCallbacks; export 'src/events/component_mixins/pointer_move_callbacks.dart' show PointerMoveCallbacks; export 'src/events/component_mixins/scale_callbacks.dart' show ScaleCallbacks; -export 'src/events/component_mixins/scale_drag_callbacks.dart' show ScaleDragCallbacks; +export 'src/events/component_mixins/scale_drag_callbacks.dart' + show ScaleDragCallbacks; export 'src/events/component_mixins/secondary_tap_callbacks.dart' show SecondaryTapCallbacks; export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks; diff --git a/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart b/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart index acae1f160fc..2fb233467ad 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart @@ -18,15 +18,15 @@ mixin ScaleDragCallbacks on Component { debugPrint("[ScaleDragCallbacks] on scale start"); _isScaling = true; } - + @mustCallSuper void onScaleUpdate(ScaleUpdateEvent event) { - debugPrint("[ScaleDragCallbacks] on scale update"); + debugPrint("[ScaleDragCallbacks] on scale update"); } @mustCallSuper void onScaleEnd(ScaleEndEvent event) { - debugPrint("[ScaleDragCallbacks] on scale end"); + debugPrint("[ScaleDragCallbacks] on scale end"); _isScaling = false; } 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 index 516bb95a19c..82612321c34 100644 --- 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 @@ -27,7 +27,8 @@ class MultiDragScaleDispatcherKey implements ComponentKey { /// [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 { +class MultiDragScaleDispatcher extends Component + implements MultiDragListener, ScaleListener { /// The record of all components currently being touched. final Set> _records = {}; @@ -230,7 +231,6 @@ class MultiDragScaleDispatcher extends Component implements MultiDragListener, S void handleScaleEnd(ScaleEndDetails details) { onScaleEnd(ScaleEndEvent(0, details)); } - //#endregion @@ -259,7 +259,7 @@ class MultiDragScaleDispatcher extends Component implements MultiDragListener, S @override GameRenderBox get renderBox => game.renderBox; - + @override void handleDragCancel(int pointerId) { // TODO: implement handleDragCancel diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index c1b7f216e9b..45c7fff7310 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -1,13 +1,15 @@ import 'dart:math' as math; import 'package:flutter/gestures.dart'; -import 'package:flutter/widgets.dart'; -/// A gesture recognizer that can recognize both individual pointer drags +/// 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. +/// +/// 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({ @@ -16,10 +18,14 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { AllowedButtonsFilter? allowedButtonsFilter, this.dragStartBehavior = DragStartBehavior.down, this.scaleThreshold = 1.05, - }) : super(allowedButtonsFilter: allowedButtonsFilter ?? _defaultButtonAcceptBehavior); + }) : super( + allowedButtonsFilter: + allowedButtonsFilter ?? _defaultButtonAcceptBehavior, + ); // Accept the input if, and only if, [kPrimaryButton] is pressed. - static bool _defaultButtonAcceptBehavior(int buttons) => buttons == kPrimaryButton; + static bool _defaultButtonAcceptBehavior(int buttons) => + buttons == kPrimaryButton; /// Determines what point is used as the starting point in all calculations. final DragStartBehavior dragStartBehavior; @@ -63,31 +69,44 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { int get pointerCount => _pointers.length; - double get _pointerScaleFactor => + double get _pointerScaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0; - double get _pointerHorizontalScaleFactor => - _initialHorizontalSpan > 0.0 ? _currentHorizontalSpan / _initialHorizontalSpan : 1.0; + double get _pointerHorizontalScaleFactor => _initialHorizontalSpan > 0.0 + ? _currentHorizontalSpan / _initialHorizontalSpan + : 1.0; - double get _pointerVerticalScaleFactor => - _initialVerticalSpan > 0.0 ? _currentVerticalSpan / _initialVerticalSpan : 1.0; + double get _pointerVerticalScaleFactor => _initialVerticalSpan > 0.0 + ? _currentVerticalSpan / _initialVerticalSpan + : 1.0; @override void addAllowedPointer(PointerDownEvent event) { assert(!_pointers.containsKey(event.pointer)); - final _DragPointerState state = _DragPointerState( + final state = _DragPointerState( recognizer: this, event: event, ); _pointers[event.pointer] = state; GestureBinding.instance.pointerRouter.addRoute(event.pointer, _handleEvent); - state._setArenaEntry(GestureBinding.instance.gestureArena.add(event.pointer, this)); + state._setArenaEntry( + 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 _DragPointerState state = _pointers[event.pointer]!; - + if (event is PointerMoveEvent) { state._move(event); _updateScale(event); @@ -95,10 +114,12 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { 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); } @@ -106,11 +127,16 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { 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; @@ -121,41 +147,132 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _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, - )); + 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)); - + _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, - )); + 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 velocity = + _scaleVelocityTracker?.getVelocity() ?? Velocity.zero; + + if (_isFlingGesture(velocity)) { + final Offset pixelsPerSecond = velocity.pixelsPerSecond; + if (pixelsPerSecond.distanceSquared > + kMaxFlingVelocity * kMaxFlingVelocity) { + final Velocity 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 double spanDelta = (_currentSpan - _initialSpan).abs(); + final double scaleFactor = _pointerScaleFactor; + + // Get the kind from any pointer state + final PointerDeviceKind 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 Offset? previousFocalPoint = _currentFocalPoint; @@ -169,11 +286,17 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { : focalPoint / _pointers.length.toDouble(); if (previousFocalPoint == null) { - _localFocalPoint = PointerEvent.transformPosition(_lastTransform, _currentFocalPoint!); + _localFocalPoint = PointerEvent.transformPosition( + _lastTransform, + _currentFocalPoint!, + ); _delta = Offset.zero; } else { final Offset localPreviousFocalPoint = _localFocalPoint; - _localFocalPoint = PointerEvent.transformPosition(_lastTransform, _currentFocalPoint!); + _localFocalPoint = PointerEvent.transformPosition( + _lastTransform, + _currentFocalPoint!, + ); _delta = _localFocalPoint - localPreviousFocalPoint; } @@ -192,8 +315,10 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { double totalVerticalDeviation = 0.0; for (final _DragPointerState state in _pointers.values) { totalDeviation += (pointerFocalPoint - state.currentPosition).distance; - totalHorizontalDeviation += (pointerFocalPoint.dx - state.currentPosition.dx).abs(); - totalVerticalDeviation += (pointerFocalPoint.dy - state.currentPosition.dy).abs(); + 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; @@ -256,7 +381,9 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { assert(_pointers.containsKey(pointer)); Drag? drag; if (onStart != null) { - drag = invokeCallback('onStart', () => onStart!(initialPosition)); + drag = invokeCallback('onStart', () { + return onStart!(initialPosition); + }); } return drag; } @@ -283,54 +410,8 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { if (!_pointers.containsKey(pointer)) { return; } - - // Check if we need to end the scale gesture before removing the pointer - final bool hadTwoOrMorePointers = _pointers.length >= 2; - final bool willHaveFewerThanTwo = _pointers.length - 1 < 2; - GestureBinding.instance.pointerRouter.removeRoute(pointer, _handleEvent); _pointers.remove(pointer)!._dispose(); - - // End scale gesture if we just dropped below 2 pointers - if (_scaleGestureActive && hadTwoOrMorePointers && willHaveFewerThanTwo) { - if (onScaleEnd != null) { - final Velocity velocity = _scaleVelocityTracker?.getVelocity() ?? Velocity.zero; - - if (_isFlingGesture(velocity)) { - final Offset pixelsPerSecond = velocity.pixelsPerSecond; - if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) { - final Velocity clampedVelocity = Velocity( - pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity, - ); - invokeCallback('onScaleEnd', () => onScaleEnd!( - ScaleEndDetails( - velocity: clampedVelocity, - scaleVelocity: velocity.pixelsPerSecond.dx, - pointerCount: _pointers.length, - ), - )); - } else { - invokeCallback('onScaleEnd', () => onScaleEnd!( - ScaleEndDetails( - velocity: velocity, - scaleVelocity: velocity.pixelsPerSecond.dx, - pointerCount: _pointers.length, - ), - )); - } - } else { - invokeCallback('onScaleEnd', () => onScaleEnd!( - ScaleEndDetails( - scaleVelocity: velocity.pixelsPerSecond.dx, - pointerCount: _pointers.length, - ), - )); - } - } - - _scaleGestureActive = false; - _scaleVelocityTracker = null; - } } @override @@ -351,18 +432,17 @@ class _DragPointerState { _DragPointerState({ required this.recognizer, required PointerDownEvent event, - }) : initialPosition = event.position, - currentPosition = event.position, - kind = event.kind { + }) : initialPosition = event.position, + currentPosition = event.position, + kind = event.kind { velocityTracker = VelocityTracker.withKind(kind); } final MultiDragScaleGestureRecognizer recognizer; final Offset initialPosition; final PointerDeviceKind kind; - - Offset currentPosition; + Offset currentPosition; late VelocityTracker velocityTracker; GestureArenaEntry? _arenaEntry; Drag? _drag; @@ -376,31 +456,45 @@ class _DragPointerState { if (!event.synthesized) { velocityTracker.addPosition(event.timeStamp, event.position); } - + final Offset delta = event.position - currentPosition; currentPosition = event.position; - if (_drag != null) { - _drag!.update(DragUpdateDetails( - globalPosition: event.position, - delta: delta, - sourceTimeStamp: event.timeStamp, - localPosition: PointerEvent.transformPosition(event.transform, event.position), - )); - } else if (!_resolved) { - // Check if we should resolve the gesture + if (!_resolved) { + // Check if we should resolve the gesture based on individual pointer movement final double 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(), - )); + _drag!.end( + DragEndDetails( + velocity: velocityTracker.getVelocity(), + ), + ); } _resolved = true; } @@ -412,8 +506,8 @@ class _DragPointerState { void _accepted(Drag? Function() starter) { if (!_resolved) { - _drag = starter(); _resolved = true; + _drag = starter(); } } @@ -440,4 +534,4 @@ class _LineBetweenPointers { final Offset pointerStartLocation; final int pointerEndId; final Offset pointerEndLocation; -} \ No newline at end of file +} 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 943340ddde8..75d0a135dc0 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 @@ -176,7 +176,7 @@ void main() { tester, startLocation1: const Offset(50, 100), offset1: const Offset(15, 2), - startLocation2: const Offset(150, 100), + startLocation2: const Offset(150, 100), offset2: const Offset(-15, -2), ); @@ -278,7 +278,6 @@ void main() { const Duration(milliseconds: 300), intervals: intervals, ); - expect(nEvents, intervals * 2 + 2); }, ); @@ -339,12 +338,10 @@ void main() { ); final component = _ScaleDragWithCallbacksComponent( - position: Vector2.all(-5), - size: Vector2.all(10), - ); - await game.world.add( - component + 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(); @@ -361,6 +358,9 @@ void main() { intervals: 10, ); + await tester.pump(); + await tester.pump(); + expect(component.scaleStartEvent, equals(1)); expect(component.scaleUpdateEvent, greaterThan(0)); expect(component.scaleEndEvent, equals(1)); @@ -428,7 +428,6 @@ mixin _ScaleDragCounter on ScaleDragCallbacks { void onScaleEnd(ScaleEndEvent event) { super.onScaleEnd(event); debugPrint("scale end counter"); - expect(event.raw, isNotNull); event.handled = true; scaleEndEvent++; if (_wasScaled != isScaling) { @@ -447,7 +446,6 @@ mixin _ScaleDragCounter on ScaleDragCallbacks { @override void onDragStart(DragStartEvent event) { super.onDragStart(event); - expect(event.raw, isNotNull); event.handled = true; dragStartEvent++; if (_wasDragged != isDragged) { @@ -458,7 +456,6 @@ mixin _ScaleDragCounter on ScaleDragCallbacks { @override void onDragUpdate(DragUpdateEvent event) { - expect(event.raw, isNotNull); event.handled = true; dragUpdateEvent++; } @@ -466,7 +463,6 @@ mixin _ScaleDragCounter on ScaleDragCallbacks { @override void onDragEnd(DragEndEvent event) { super.onDragEnd(event); - expect(event.raw, isNotNull); event.handled = true; dragEndEvent++; if (_wasDragged != isDragged) { @@ -487,7 +483,6 @@ class _SimpleScaleDragCallbacksComponent extends PositionComponent _SimpleScaleDragCallbacksComponent({super.size}); } - class _ScaleDragWithCallbacksComponent extends PositionComponent with ScaleDragCallbacks, _ScaleDragCounter { _ScaleDragWithCallbacksComponent({ From b78a284893e94151f22e44707e9a9b66b905c5d3 Mon Sep 17 00:00:00 2001 From: stilnat Date: Mon, 24 Nov 2025 02:30:51 +0100 Subject: [PATCH 32/87] rewrite scale and drag callbacks without testing --- .../component_mixins/drag_callbacks.dart | 27 +- .../component_mixins/scale_callbacks.dart | 14 +- .../multi_drag_dispatcher.dart | 52 ++-- .../flame_game_mixins/scale_dispatcher.dart | 241 ++---------------- 4 files changed, 73 insertions(+), 261 deletions(-) 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..0041e6fa95c 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,31 @@ 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 good + if (multiDragScaleDispatcher != null || 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) { + 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..210d84c84b7 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -1,5 +1,6 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; +import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart'; import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flutter/foundation.dart'; @@ -25,11 +26,22 @@ 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()); + + 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..cc3dc27049a 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 @@ -28,32 +28,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 +112,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 +124,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 +131,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,7 +139,20 @@ 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(); + } + + void _tryRemoving(){ + // there's no more fingers + // that started dragging before _shouldBeRemoved flag was set to true. + if(_records.isEmpty && _shouldBeRemoved){ + removeFromParent(); + } } //#endregion @@ -179,10 +171,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 62c5912e7f3..aff3724cdb7 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 @@ -5,32 +5,9 @@ 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(); @@ -58,11 +35,7 @@ class ScaleDispatcher extends Component implements ScaleListener { DragUpdateDetails? lastDragUpdate; DragEndDetails? lastDragEnd; - _LineBetweenPointers? _currentLine; - - _LineBetweenPointers? _lineAtFirstUpdate; - - MultiDragDispatcher? _multiDragDispatcher; + bool _shouldBeRemoved = false; /// Called when the user starts a scale gesture. @mustCallSuper @@ -128,173 +101,31 @@ 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(); + } + + void _tryRemoving(){ + // there's no more fingers + // that started scaling before _shouldBeRemoved flag was set to true. + if(_records.isEmpty && _shouldBeRemoved){ + removeFromParent(); + } + } + @override void onMount() { game.gestureDetectors.add( @@ -306,52 +137,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; - }); - } } From 0ff1070abe69ee77fbc6518fd81a756851ceea21 Mon Sep 17 00:00:00 2001 From: stilnat Date: Mon, 24 Nov 2025 15:02:56 +0100 Subject: [PATCH 33/87] make test pass without scaledrag callbacks --- .../component_mixins/scale_callbacks.dart | 5 + .../scale_drag_dispatcher.dart | 56 +---- .../scale_drag_callbacks_test.dart | 220 +++++++++++++----- 3 files changed, 174 insertions(+), 107 deletions(-) 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 210d84c84b7..c22d143e98d 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -12,6 +12,7 @@ mixin ScaleCallbacks on Component { @mustCallSuper void onScaleStart(ScaleStartEvent event) { + debugPrint("ScaleCallbacks.onScaleStart called!"); _isScaling = true; } @@ -32,6 +33,10 @@ mixin ScaleCallbacks on Component { final game = findRootGame()!; 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(); 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 index 82612321c34..3f85467df5d 100644 --- 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 @@ -30,31 +30,9 @@ class MultiDragScaleDispatcherKey implements ComponentKey { class MultiDragScaleDispatcher extends Component implements MultiDragListener, ScaleListener { /// The record of all components currently being touched. - final Set> _records = {}; + 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; @@ -73,7 +51,7 @@ class MultiDragScaleDispatcher extends Component debugPrint("dispatcher on drag start"); event.deliverAtPoint( rootComponent: game, - eventHandler: (ScaleDragCallbacks component) { + eventHandler: (DragCallbacks component) { _records.add(TaggedComponent(event.pointerId, component)); component.onDragStart(event); }, @@ -88,11 +66,11 @@ class MultiDragScaleDispatcher extends Component /// delivered, however its `event.localPosition` property will contain NaNs. @mustCallSuper void onDragUpdate(DragUpdateEvent event) { - final updated = >{}; + final updated = >{}; event.deliverAtPoint( rootComponent: game, deliverToAll: true, - eventHandler: (ScaleDragCallbacks component) { + eventHandler: (DragCallbacks component) { final record = TaggedComponent(event.pointerId, component); if (_records.contains(record)) { component.onDragUpdate(event); @@ -130,7 +108,6 @@ class MultiDragScaleDispatcher extends Component void handleDragStart(int pointerId, DragStartDetails details) { final event = DragStartEvent(pointerId, game, details); onDragStart(event); - _dragStartController.add(event); } @internal @@ -138,7 +115,6 @@ class MultiDragScaleDispatcher extends Component void handleDragUpdate(int pointerId, DragUpdateDetails details) { final event = DragUpdateEvent(pointerId, game, details); onDragUpdate(event); - _dragUpdateController.add(event); } @internal @@ -146,17 +122,16 @@ class MultiDragScaleDispatcher extends Component void handleDragEnd(int pointerId, DragEndDetails details) { final event = DragEndEvent(pointerId, details); onDragEnd(event); - _dragEndController.add(event); } - final Set> _scaleRecords = {}; + final Set> _scaleRecords = {}; /// Called when the user starts a scale gesture. @mustCallSuper void onScaleStart(ScaleStartEvent event) { event.deliverAtPoint( rootComponent: game, - eventHandler: (ScaleDragCallbacks component) { + eventHandler: (ScaleCallbacks component) { _scaleRecords.add(TaggedComponent(event.pointerId, component)); component.onScaleStart(event); }, @@ -166,13 +141,13 @@ class MultiDragScaleDispatcher extends Component /// Called continuously as the user updates the scale gesture. @mustCallSuper void onScaleUpdate(ScaleUpdateEvent event) { - final updated = >{}; + final updated = >{}; // Deliver to components under the pointer event.deliverAtPoint( rootComponent: game, deliverToAll: true, - eventHandler: (ScaleDragCallbacks component) { + eventHandler: (ScaleCallbacks component) { final record = TaggedComponent(event.pointerId, component); if (_scaleRecords.contains(record)) { component.onScaleUpdate(event); @@ -215,15 +190,7 @@ class MultiDragScaleDispatcher extends Component @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)); - }*/ + onScaleUpdate(ScaleUpdateEvent(0, game, details)); } @internal @@ -236,6 +203,7 @@ class MultiDragScaleDispatcher extends Component @override void onMount() { + debugPrint("mount scale drag gesture recognizer"); game.gestureDetectors.add( MultiDragScaleGestureRecognizer.new, (MultiDragScaleGestureRecognizer instance) { @@ -251,10 +219,6 @@ class MultiDragScaleDispatcher extends Component void onRemove() { game.gestureDetectors.remove(); game.unregisterKey(const MultiDragScaleDispatcherKey()); - _dragUpdateController.close(); - _dragCancelController.close(); - _dragStartController.close(); - _dragEndController.close(); } @override 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 75d0a135dc0..d2ddf6ea839 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 @@ -47,7 +47,7 @@ void main() { ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; dispatcher.onScaleStart( createScaleStartEvents( @@ -171,6 +171,8 @@ void main() { await tester.pump(); expect(game.children.length, equals(3)); expect(game.isMounted, isTrue); + await tester.pump(); + await tester.pump(); await _zoomFrom( tester, @@ -395,54 +397,90 @@ Future _zoomFrom( await tester.pump(); } -mixin _ScaleDragCounter on ScaleDragCallbacks { - int scaleStartEvent = 0; - int scaleUpdateEvent = 0; - int scaleEndEvent = 0; +/*class _ScaleDragCallbacksComponent extends PositionComponent + with ScaleDragCallbacks, _ScaleDragCounter {}*/ +class _ScaleDragCallbacksComponent extends PositionComponent + with ScaleCallbacks, DragCallbacks, _ScaleCounter, _DragCounter {} - int isScaledStateChange = 0; +class _ScaleDragCallbacksGame extends FlameGame + with ScaleCallbacks, DragCallbacks, _ScaleCounter, _DragCounter {} - bool _wasScaled = false; +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); - expect(event.raw, isNotNull); - event.handled = true; - scaleStartEvent++; - if (_wasScaled != isScaling) { - ++isScaledStateChange; - _wasScaled = isScaling; - } + return _onScaleStart?.call(event); } @override void onScaleUpdate(ScaleUpdateEvent event) { super.onScaleUpdate(event); - expect(event.raw, isNotNull); - event.handled = true; - scaleUpdateEvent++; + return _onScaleUpdate?.call(event); } @override void onScaleEnd(ScaleEndEvent event) { super.onScaleEnd(event); - debugPrint("scale end counter"); - event.handled = true; - scaleEndEvent++; - if (_wasScaled != isScaling) { - ++isScaledStateChange; - _wasScaled = isScaling; - } + return _onScaleEnd?.call(event); } - bool _wasDragged = false; + @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); + } +} + +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); @@ -456,6 +494,7 @@ mixin _ScaleDragCounter on ScaleDragCallbacks { @override void onDragUpdate(DragUpdateEvent event) { + super.onDragUpdate(event); event.handled = true; dragUpdateEvent++; } @@ -470,78 +509,137 @@ mixin _ScaleDragCounter on ScaleDragCallbacks { _wasDragged = isDragged; } } + + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + event.handled = true; + dragCancelEvent++; + } } -class _ScaleDragCallbacksComponent extends PositionComponent - with ScaleDragCallbacks, _ScaleDragCounter {} +mixin _ScaleCounter on ScaleCallbacks { + int scaleStartEvent = 0; + int scaleUpdateEvent = 0; + int scaleEndEvent = 0; -class _ScaleDragCallbacksGame extends FlameGame - with ScaleDragCallbacks, _ScaleDragCounter {} + int isScaledStateChange = 0; + + bool _wasScaled = false; + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + debugPrint("_ScaleCounter.onScaleStart called!"); + 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 _SimpleScaleDragCallbacksComponent extends PositionComponent - with ScaleDragCallbacks { - _SimpleScaleDragCallbacksComponent({super.size}); } -class _ScaleDragWithCallbacksComponent extends PositionComponent - with ScaleDragCallbacks, _ScaleDragCounter { - _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; +mixin _ScaleDragCounter on ScaleDragCallbacks { + int scaleStartEvent = 0; + int scaleUpdateEvent = 0; + int scaleEndEvent = 0; - 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; + int isScaledStateChange = 0; + + bool _wasScaled = false; @override void onScaleStart(ScaleStartEvent event) { super.onScaleStart(event); - return _onScaleStart?.call(event); + expect(event.raw, isNotNull); + event.handled = true; + scaleStartEvent++; + if (_wasScaled != isScaling) { + ++isScaledStateChange; + _wasScaled = isScaling; + } } @override void onScaleUpdate(ScaleUpdateEvent event) { super.onScaleUpdate(event); - return _onScaleUpdate?.call(event); + expect(event.raw, isNotNull); + event.handled = true; + scaleUpdateEvent++; } @override void onScaleEnd(ScaleEndEvent event) { super.onScaleEnd(event); - return _onScaleEnd?.call(event); + expect(event.raw, isNotNull); + event.handled = true; + scaleEndEvent++; + if (_wasScaled != isScaling) { + ++isScaledStateChange; + _wasScaled = isScaling; + } } + 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); - return _onDragStart?.call(event); + expect(event.raw, isNotNull); + event.handled = true; + dragStartEvent++; + if (_wasDragged != isDragged) { + ++isDraggedStateChange; + _wasDragged = isDragged; + } } @override void onDragUpdate(DragUpdateEvent event) { super.onDragUpdate(event); - return _onDragUpdate?.call(event); + expect(event.raw, isNotNull); + event.handled = true; + dragUpdateEvent++; } @override void onDragEnd(DragEndEvent event) { super.onDragEnd(event); - return _onDragEnd?.call(event); + expect(event.raw, isNotNull); + event.handled = true; + dragEndEvent++; + if (_wasDragged != isDragged) { + ++isDraggedStateChange; + _wasDragged = isDragged; + } } } From b21afe38c4326981de35b65059fc58f22d86e23a Mon Sep 17 00:00:00 2001 From: stilnat Date: Mon, 24 Nov 2025 16:39:41 +0100 Subject: [PATCH 34/87] fix drag and scale callbacks to upgrade to scaleDragDispatcher --- .../component_mixins/drag_callbacks.dart | 21 +++++++++++++++---- .../component_mixins/scale_callbacks.dart | 2 +- .../multi_drag_dispatcher.dart | 12 +++++++++-- .../flame_game_mixins/scale_dispatcher.dart | 11 +++++++--- 4 files changed, 36 insertions(+), 10 deletions(-) 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 0041e6fa95c..e60d6946193 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -62,14 +62,26 @@ mixin DragCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); - + final game = findRootGame()!; final scaleDispatcher = game.findByKey(const ScaleDispatcherKey()); final multiDragDispatcher = game.findByKey(const MultiDragDispatcherKey()); final multiDragScaleDispatcher = game.findByKey(const MultiDragScaleDispatcherKey()); - // If MultiDragScaleDispatcher already exists, we're good - if (multiDragScaleDispatcher != null || multiDragDispatcher != null) return; + // 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 @@ -83,10 +95,11 @@ mixin DragCallbacks on Component { 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(); + //(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 c22d143e98d..d9238140ca4 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -46,7 +46,7 @@ mixin ScaleCallbacks on Component { final dispatcher = MultiDragScaleDispatcher(); game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); - (multiDragDispatcher as MultiDragDispatcher).markForRemoval(); + //(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 cc3dc27049a..3aa36fa5483 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 @@ -7,6 +7,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 { @@ -147,18 +148,25 @@ class MultiDragDispatcher extends Component implements MultiDragListener { _tryRemoving(); } - void _tryRemoving(){ + bool _tryRemoving(){ // there's no more fingers // that started dragging before _shouldBeRemoved flag was set to true. - if(_records.isEmpty && _shouldBeRemoved){ + if(_records.isEmpty && _shouldBeRemoved && isMounted){ removeFromParent(); + return true; } + return false; } //#endregion @override void onMount() { + if(_tryRemoving()){ + return; + }; + + debugPrint("mount multi drag gesture recognizer"); game.gestureDetectors.add( ImmediateMultiDragGestureRecognizer.new, (ImmediateMultiDragGestureRecognizer instance) { 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 aff3724cdb7..6e6335e17c9 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 @@ -118,16 +118,21 @@ class ScaleDispatcher extends Component implements ScaleListener { _tryRemoving(); } - void _tryRemoving(){ + bool _tryRemoving(){ // there's no more fingers - // that started scaling before _shouldBeRemoved flag was set to true. - if(_records.isEmpty && _shouldBeRemoved){ + // 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) { From bf1398a0c48fc123431e1652b0f2e4e1f00bfcca Mon Sep 17 00:00:00 2001 From: stilnat Date: Mon, 24 Nov 2025 19:03:06 +0100 Subject: [PATCH 35/87] add a bunch of tests --- packages/flame/lib/events.dart | 6 +- .../scale_drag_callbacks.dart | 68 ----- .../multi_drag_dispatcher.dart | 4 +- .../flame_game_mixins/scale_dispatcher.dart | 9 +- .../scale_drag_dispatcher.dart | 5 - .../scale_drag_callbacks_test.dart | 286 ++++++++++++++---- 6 files changed, 227 insertions(+), 151 deletions(-) delete mode 100644 packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index d9811cc5e5d..2ff9f4a0f9e 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -5,8 +5,6 @@ export 'src/events/component_mixins/hover_callbacks.dart' show HoverCallbacks; export 'src/events/component_mixins/pointer_move_callbacks.dart' show PointerMoveCallbacks; export 'src/events/component_mixins/scale_callbacks.dart' show ScaleCallbacks; -export 'src/events/component_mixins/scale_drag_callbacks.dart' - show ScaleDragCallbacks; export 'src/events/component_mixins/secondary_tap_callbacks.dart' show SecondaryTapCallbacks; export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks; @@ -14,12 +12,12 @@ export 'src/events/flame_game_mixins/double_tap_dispatcher.dart' show DoubleTapDispatcher, DoubleTapDispatcherKey; export 'src/events/flame_game_mixins/multi_drag_dispatcher.dart' show MultiDragDispatcher, MultiDragDispatcherKey; -export 'src/events/flame_game_mixins/scale_drag_dispatcher.dart' - show MultiDragScaleDispatcher, MultiDragScaleDispatcherKey; 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/scale_drag_callbacks.dart b/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart deleted file mode 100644 index 2fb233467ad..00000000000 --- a/packages/flame/lib/src/events/component_mixins/scale_drag_callbacks.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/src/events/flame_game_mixins/scale_drag_dispatcher.dart'; -import 'package:flutter/widgets.dart'; -import 'package:meta/meta.dart'; - -mixin ScaleDragCallbacks on Component { - bool _isScaling = false; - bool _isDragged = false; - - /// Returns true while the component is being scaled. - bool get isScaling => _isScaling; - - bool get isDragged => _isDragged; - - @mustCallSuper - void onScaleStart(ScaleStartEvent event) { - debugPrint("[ScaleDragCallbacks] on scale start"); - _isScaling = true; - } - - @mustCallSuper - void onScaleUpdate(ScaleUpdateEvent event) { - debugPrint("[ScaleDragCallbacks] on scale update"); - } - - @mustCallSuper - void onScaleEnd(ScaleEndEvent event) { - debugPrint("[ScaleDragCallbacks] on scale end"); - _isScaling = false; - } - - @mustCallSuper - void onDragStart(DragStartEvent event) { - _isDragged = true; - } - - /// The user has moved the pointer that initiated the drag gesture. - /// - /// This event will be delivered to the component(s) that captured the initial - /// [onDragStart], even if the point of touch moves outside of the boundaries - /// of the component. In the latter case `event.localPosition` will contain a - /// NaN point. - @mustCallSuper - void onDragUpdate(DragUpdateEvent event) {} - - /// The drag event has ended. - /// - /// This event will be delivered to the component(s) that captured the initial - /// [onDragStart], even if the point of touch moves outside of the boundaries - /// of the component. - @mustCallSuper - void onDragEnd(DragEndEvent event) { - _isDragged = false; - } - - @override - @mustCallSuper - void onMount() { - super.onMount(); - final game = findRootGame()!; - if (game.findByKey(const MultiDragScaleDispatcherKey()) == null) { - final dispatcher = MultiDragScaleDispatcher(); - game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); - game.add(dispatcher); - } - } -} 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 3aa36fa5483..515cc164be8 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'; @@ -165,7 +163,7 @@ class MultiDragDispatcher extends Component implements MultiDragListener { if(_tryRemoving()){ return; }; - + debugPrint("mount multi drag gesture recognizer"); game.gestureDetectors.add( ImmediateMultiDragGestureRecognizer.new, 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 6e6335e17c9..b8756e4360b 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,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/game.dart'; @@ -30,11 +28,6 @@ class ScaleDispatcher extends Component implements ScaleListener { FlameGame get game => parent! as FlameGame; - /// Store the last drag events - DragStartDetails? lastDragStart; - DragUpdateDetails? lastDragUpdate; - DragEndDetails? lastDragEnd; - bool _shouldBeRemoved = false; /// Called when the user starts a scale gesture. @@ -132,7 +125,7 @@ class ScaleDispatcher extends Component implements ScaleListener { void onMount() { if(_tryRemoving()){ return; - }; + } game.gestureDetectors.add( ScaleGestureRecognizer.new, (ScaleGestureRecognizer instance) { 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 index 3f85467df5d..4c298419e81 100644 --- 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 @@ -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'; @@ -8,7 +6,6 @@ import 'package:flame/src/events/multi_drag_scale_recognizer.dart'; import 'package:flame/src/events/tagged_component.dart'; import 'package:flame/src/game/flame_game.dart'; import 'package:flame/src/game/game_render_box.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; @@ -48,7 +45,6 @@ class MultiDragScaleDispatcher extends Component /// that may occur simultaneously. @mustCallSuper void onDragStart(DragStartEvent event) { - debugPrint("dispatcher on drag start"); event.deliverAtPoint( rootComponent: game, eventHandler: (DragCallbacks component) { @@ -203,7 +199,6 @@ class MultiDragScaleDispatcher extends Component @override void onMount() { - debugPrint("mount scale drag gesture recognizer"); game.gestureDetectors.add( MultiDragScaleGestureRecognizer.new, (MultiDragScaleGestureRecognizer instance) { 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 d2ddf6ea839..837dbe08486 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 @@ -12,7 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('ScaleCallbacks', () { testWithFlameGame( - 'make sure ScaleDragCallback components can be added to a FlameGame', + 'make sure components can be added to a FlameGame', (game) async { await game.add(_ScaleDragCallbacksComponent()); await game.ready(); @@ -371,6 +371,182 @@ void main() { 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()[2], 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 dragWithInjection( + tester, + center, + const Offset(20, 0), + const Duration(milliseconds: 200), + injectScale, // ajout à mi-chemin + ); + }, + ); + + 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 _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, + ); + }, + ); + +} + + + +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) { + // On est au milieu : injecte ton scale component + 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(); } Future _zoomFrom( @@ -397,8 +573,6 @@ Future _zoomFrom( await tester.pump(); } -/*class _ScaleDragCallbacksComponent extends PositionComponent - with ScaleDragCallbacks, _ScaleDragCounter {}*/ class _ScaleDragCallbacksComponent extends PositionComponent with ScaleCallbacks, DragCallbacks, _ScaleCounter, _DragCounter {} @@ -530,7 +704,6 @@ mixin _ScaleCounter on ScaleCallbacks { @override void onScaleStart(ScaleStartEvent event) { super.onScaleStart(event); - debugPrint("_ScaleCounter.onScaleStart called!"); expect(event.raw, isNotNull); event.handled = true; scaleStartEvent++; @@ -562,87 +735,74 @@ mixin _ScaleCounter on ScaleCallbacks { } -mixin _ScaleDragCounter on ScaleDragCallbacks { - int scaleStartEvent = 0; - int scaleUpdateEvent = 0; - int scaleEndEvent = 0; - - int isScaledStateChange = 0; +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; - bool _wasScaled = false; + final void Function(DragStartEvent)? _onDragStart; + final void Function(DragUpdateEvent)? _onDragUpdate; + final void Function(DragEndEvent)? _onDragEnd; @override - void onScaleStart(ScaleStartEvent event) { - super.onScaleStart(event); - expect(event.raw, isNotNull); - event.handled = true; - scaleStartEvent++; - if (_wasScaled != isScaling) { - ++isScaledStateChange; - _wasScaled = isScaling; - } + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + return _onDragStart?.call(event); } @override - void onScaleUpdate(ScaleUpdateEvent event) { - super.onScaleUpdate(event); - expect(event.raw, isNotNull); - event.handled = true; - scaleUpdateEvent++; + void onDragUpdate(DragUpdateEvent event) { + return _onDragUpdate?.call(event); } @override - void onScaleEnd(ScaleEndEvent event) { - super.onScaleEnd(event); - expect(event.raw, isNotNull); - event.handled = true; - scaleEndEvent++; - if (_wasScaled != isScaling) { - ++isScaledStateChange; - _wasScaled = isScaling; - } + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + return _onDragEnd?.call(event); } - int dragStartEvent = 0; - int dragUpdateEvent = 0; - int dragEndEvent = 0; - int dragCancelEvent = 0; - int isDraggedStateChange = 0; +} - bool _wasDragged = false; +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 onDragStart(DragStartEvent event) { - super.onDragStart(event); - expect(event.raw, isNotNull); - event.handled = true; - dragStartEvent++; - if (_wasDragged != isDragged) { - ++isDraggedStateChange; - _wasDragged = isDragged; - } + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + return _onScaleStart?.call(event); } @override - void onDragUpdate(DragUpdateEvent event) { - super.onDragUpdate(event); - expect(event.raw, isNotNull); - event.handled = true; - dragUpdateEvent++; + void onScaleUpdate(ScaleUpdateEvent event) { + return _onScaleUpdate?.call(event); } @override - void onDragEnd(DragEndEvent event) { - super.onDragEnd(event); - expect(event.raw, isNotNull); - event.handled = true; - dragEndEvent++; - if (_wasDragged != isDragged) { - ++isDraggedStateChange; - _wasDragged = isDragged; - } + void onScaleEnd(ScaleEndEvent event) { + super.onScaleEnd(event); + return _onScaleEnd?.call(event); } } + // Source - https://stackoverflow.com/a/75171528 // Posted by Alexander // Retrieved 2025-11-19, License - CC BY-SA 4.0 From 58e986fd8fdbfc373994a1b395f24ec4b47b1ec0 Mon Sep 17 00:00:00 2001 From: stilnat Date: Mon, 24 Nov 2025 23:59:29 +0100 Subject: [PATCH 36/87] fix format and stuff --- examples/lib/stories/input/scale_example.dart | 587 +----------------- .../component_mixins/drag_callbacks.dart | 25 +- .../component_mixins/scale_callbacks.dart | 20 +- .../multi_drag_dispatcher.dart | 17 +- .../flame_game_mixins/scale_dispatcher.dart | 12 +- .../scale_drag_dispatcher.dart | 25 +- .../events/multi_drag_scale_recognizer.dart | 87 +-- .../scale_drag_callbacks_test.dart | 368 ++++++----- 8 files changed, 281 insertions(+), 860 deletions(-) diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_example.dart index acae59bed3f..886061808e3 100644 --- a/examples/lib/stories/input/scale_example.dart +++ b/examples/lib/stories/input/scale_example.dart @@ -1,28 +1,13 @@ import 'dart:math'; -import 'dart:math' as math; import 'package:flame/components.dart' hide Matrix4; import 'package:flame/events.dart' hide PointerMoveEvent; import 'package:flame/game.dart' hide Matrix4; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide PointerMoveEvent, Matrix4; void main() { runApp(GameWidget(game: ScaleExample())); } -class MyDrag extends Drag { - @override - void update(DragUpdateDetails details) { - // This specific finger moved by details.delta - print('This finger moved: ${details.delta}'); - } - - @override - void end(DragEndDetails details) { - print('This finger lifted'); - } -} - class ScaleExample extends FlameGame { late RectangleComponent rect; late TextComponent debugText; @@ -32,36 +17,14 @@ class ScaleExample extends FlameGame { double startingZoom = 1; final bool addScaleOnlyRectangle = false; - final bool addDragOnlyRectangle = false; - final bool addScaleDragRectangle = false; + final bool addDragOnlyRectangle = true; + final bool addScaleDragRectangle = true; final bool addZoom = false; final bool addCameraRotation = false; - Drag onDragStart(Offset initialPosition) { - debugPrint('Drag started'); - return MyDrag(); - } - - void onScaleStart(DragEndEvent event) { - debugPrint('Drag ended with velocity ${event.velocity}'); - } - - void onScaleUpdate(ScaleUpdateDetails event) { - debugPrint('scale update'); - } - @override Future onLoad() async { camera.viewfinder.zoom = 1; - (parent! as FlameGame).gestureDetectors - .add( - MultiDragScaleGestureRecognizer.new, - (MultiDragScaleGestureRecognizer instance) { - instance - ..onDragStart = onDragStart - ..onScaleUpdate = onScaleUpdate; - }, - ); debugText = TextComponent( text: 'hello', @@ -89,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, ); @@ -156,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); @@ -247,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); @@ -329,543 +288,3 @@ class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { debugPrint('Scale ended with velocity ${event.velocity}'); } } - -/// A gesture recognizer that can recognize both individual pointer drags -/// and scale gestures simultaneously. -/// -/// This recognizer tracks each pointer individually (like ImmediateMultiDragGestureRecognizer) -/// while also tracking the overall scale gesture (like ScaleGestureRecognizer). -/// When 2+ pointers are down, both drag callbacks (per pointer) and scale callbacks fire. -class MultiDragScaleGestureRecognizer extends OneSequenceGestureRecognizer { - /// Create a gesture recognizer for tracking multi-drag and scale gestures. - MultiDragScaleGestureRecognizer({ - super.debugOwner, - super.supportedDevices, - super.allowedButtonsFilter, - this.dragStartBehavior = DragStartBehavior.down, - this.scaleThreshold = 1.05, - }); - - /// 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? onDragStart; - - /// 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; - - _GestureState _state = _GestureState.ready; - bool _scaleGestureActive = false; - - final Map _pointerLocations = {}; - final Map _initialPointerLocations = {}; - final List _pointerQueue = []; - final Map _velocityTrackers = {}; - final Map _activeDrags = {}; - final Map _lastPointerPositions = {}; - - // Scale-specific fields - late Offset _initialFocalPoint; - Offset? _currentFocalPoint; - late double _initialSpan; - late double _currentSpan; - late double _initialHorizontalSpan; - late double _currentHorizontalSpan; - late double _initialVerticalSpan; - late double _currentVerticalSpan; - late Offset _localFocalPoint; - _LineBetweenPointers? _initialLine; - _LineBetweenPointers? _currentLine; - Matrix4? _lastTransform; - late Offset _delta; - VelocityTracker? _scaleVelocityTracker; - Duration? _initialScaleEventTimestamp; - - int get pointerCount => _pointerLocations.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) { - super.addAllowedPointer(event); - _velocityTrackers[event.pointer] = VelocityTracker.withKind(event.kind); - _initialPointerLocations[event.pointer] = event.position; - _lastPointerPositions[event.pointer] = event.position; - - if (_state == _GestureState.ready) { - _state = _GestureState.possible; - _initialSpan = 0.0; - _currentSpan = 0.0; - _initialHorizontalSpan = 0.0; - _currentHorizontalSpan = 0.0; - _initialVerticalSpan = 0.0; - _currentVerticalSpan = 0.0; - } - } - - @override - void handleEvent(PointerEvent event) { - assert(_state != _GestureState.ready); - bool didChangeConfiguration = false; - bool shouldStartIfAccepted = false; - - if (event is PointerMoveEvent) { - final VelocityTracker tracker = _velocityTrackers[event.pointer]!; - if (!event.synthesized) { - tracker.addPosition(event.timeStamp, event.position); - } - - final Offset lastPosition = _lastPointerPositions[event.pointer]!; - final Offset delta = event.position - lastPosition; - _lastPointerPositions[event.pointer] = event.position; - - // Update individual drag - final Drag? drag = _activeDrags[event.pointer]; - if (drag != null) { - drag.update( - DragUpdateDetails( - globalPosition: event.position, - delta: delta, - primaryDelta: delta.dy, - sourceTimeStamp: event.timeStamp, - localPosition: PointerEvent.transformPosition( - event.transform, - event.position, - ), - ), - ); - } - - _pointerLocations[event.pointer] = event.position; - shouldStartIfAccepted = true; - _lastTransform = event.transform; - } else if (event is PointerDownEvent) { - _pointerLocations[event.pointer] = event.position; - _pointerQueue.add(event.pointer); - didChangeConfiguration = true; - shouldStartIfAccepted = true; - _lastTransform = event.transform; - } else if (event is PointerUpEvent || event is PointerCancelEvent) { - // End individual drag - final Drag? drag = _activeDrags[event.pointer]; - if (drag != null) { - final VelocityTracker tracker = _velocityTrackers[event.pointer]!; - if (event is PointerUpEvent) { - drag.end( - DragEndDetails( - velocity: tracker.getVelocity(), - primaryVelocity: tracker.getVelocity().pixelsPerSecond.dy, - ), - ); - } else { - drag.cancel(); - } - _activeDrags.remove(event.pointer); - } - - _pointerLocations.remove(event.pointer); - _initialPointerLocations.remove(event.pointer); - _lastPointerPositions.remove(event.pointer); - _pointerQueue.remove(event.pointer); - didChangeConfiguration = true; - _lastTransform = event.transform; - } - - _updateLines(); - _update(); - - if (!didChangeConfiguration || _reconfigure(event.pointer)) { - _advanceStateMachine(shouldStartIfAccepted, event); - } - stopTrackingIfPointerNoLongerDown(event); - } - - void _update() { - final Offset? previousFocalPoint = _currentFocalPoint; - - // Compute the focal point - Offset focalPoint = Offset.zero; - for (final int pointer in _pointerLocations.keys) { - focalPoint += _pointerLocations[pointer]!; - } - _currentFocalPoint = _pointerLocations.isEmpty - ? Offset.zero - : focalPoint / _pointerLocations.length.toDouble(); - - if (previousFocalPoint == null) { - _localFocalPoint = PointerEvent.transformPosition( - _lastTransform, - _currentFocalPoint!, - ); - _delta = Offset.zero; - } else { - final Offset localPreviousFocalPoint = _localFocalPoint; - _localFocalPoint = PointerEvent.transformPosition( - _lastTransform, - _currentFocalPoint!, - ); - _delta = _localFocalPoint - localPreviousFocalPoint; - } - - final int count = _pointerLocations.keys.length; - Offset pointerFocalPoint = Offset.zero; - for (final int pointer in _pointerLocations.keys) { - pointerFocalPoint += _pointerLocations[pointer]!; - } - if (count > 0) { - pointerFocalPoint = pointerFocalPoint / count.toDouble(); - } - - // Calculate span - double totalDeviation = 0.0; - double totalHorizontalDeviation = 0.0; - double totalVerticalDeviation = 0.0; - for (final int pointer in _pointerLocations.keys) { - totalDeviation += - (pointerFocalPoint - _pointerLocations[pointer]!).distance; - totalHorizontalDeviation += - (pointerFocalPoint.dx - _pointerLocations[pointer]!.dx).abs(); - totalVerticalDeviation += - (pointerFocalPoint.dy - _pointerLocations[pointer]!.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 int count = _pointerLocations.keys.length; - assert(_pointerQueue.length >= count); - - if (count < 2) { - _initialLine = _currentLine; - } else if (_initialLine != null && - _initialLine!.pointerStartId == _pointerQueue[0] && - _initialLine!.pointerEndId == _pointerQueue[1]) { - _currentLine = _LineBetweenPointers( - pointerStartId: _pointerQueue[0], - pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, - pointerEndId: _pointerQueue[1], - pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, - ); - } else { - _initialLine = _LineBetweenPointers( - pointerStartId: _pointerQueue[0], - pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, - pointerEndId: _pointerQueue[1], - pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, - ); - _currentLine = _initialLine; - } - } - - bool _reconfigure(int pointer) { - _initialFocalPoint = _currentFocalPoint!; - _initialSpan = _currentSpan; - _initialLine = _currentLine; - _initialHorizontalSpan = _currentHorizontalSpan; - _initialVerticalSpan = _currentVerticalSpan; - - if (_state == _GestureState.started && _scaleGestureActive) { - // Check if we should end the scale gesture - if (_pointerLocations.length < 2) { - if (onScaleEnd != null) { - final VelocityTracker tracker = _velocityTrackers[pointer]!; - Velocity velocity = tracker.getVelocity(); - - if (_isFlingGesture(velocity)) { - final Offset pixelsPerSecond = velocity.pixelsPerSecond; - if (pixelsPerSecond.distanceSquared > - kMaxFlingVelocity * kMaxFlingVelocity) { - velocity = Velocity( - pixelsPerSecond: - (pixelsPerSecond / pixelsPerSecond.distance) * - kMaxFlingVelocity, - ); - } - invokeCallback( - 'onScaleEnd', - () => onScaleEnd!( - ScaleEndDetails( - velocity: velocity, - scaleVelocity: - _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? - -1, - pointerCount: pointerCount, - ), - ), - ); - } else { - invokeCallback( - 'onScaleEnd', - () => onScaleEnd!( - ScaleEndDetails( - scaleVelocity: - _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? - -1, - pointerCount: pointerCount, - ), - ), - ); - } - } - _scaleGestureActive = false; - _state = _GestureState.accepted; - _scaleVelocityTracker = VelocityTracker.withKind( - PointerDeviceKind.touch, - ); - return false; - } - } - - _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); - return true; - } - - void _advanceStateMachine(bool shouldStartIfAccepted, PointerEvent event) { - if (_state == _GestureState.ready) { - _state = _GestureState.possible; - } - - if (_state == _GestureState.possible) { - // Check if we should start accepting gestures - bool shouldAccept = false; - - if (_pointerLocations.length >= 2) { - final double spanDelta = (_currentSpan - _initialSpan).abs(); - final double scaleFactor = _pointerScaleFactor; - - if (spanDelta > computeScaleSlop(event.kind) || - math.max(scaleFactor, 1.0 / scaleFactor) > scaleThreshold) { - shouldAccept = true; - } - } else if (_pointerLocations.length == 1) { - final int pointer = _pointerLocations.keys.first; - final Offset initialPosition = _initialPointerLocations[pointer]!; - final Offset currentPosition = _pointerLocations[pointer]!; - final double distance = (currentPosition - initialPosition).distance; - - if (distance > computePanSlop(event.kind, gestureSettings)) { - shouldAccept = true; - } - } - - if (shouldAccept) { - resolve(GestureDisposition.accepted); - } - } else if (_state.index >= _GestureState.accepted.index) { - resolve(GestureDisposition.accepted); - } - - if (_state == _GestureState.accepted && shouldStartIfAccepted) { - _state = _GestureState.started; - - // Start individual drags for all pointers that don't have one yet - for (final int pointer in _pointerLocations.keys) { - if (!_activeDrags.containsKey(pointer)) { - _startDragForPointer(pointer); - } - } - - // Start scale gesture if we have 2+ pointers - if (_pointerLocations.length >= 2 && !_scaleGestureActive) { - _scaleGestureActive = true; - _initialScaleEventTimestamp = event.timeStamp; - _dispatchOnScaleStartCallback(); - } - } - - if (_state == _GestureState.started) { - // Update scale gesture if active - if (_scaleGestureActive && _pointerLocations.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, - ), - ); - }); - } - } else if (!_scaleGestureActive && _pointerLocations.length >= 2) { - // We just got a second pointer, start scale gesture - _scaleGestureActive = true; - _initialScaleEventTimestamp = event.timeStamp; - _dispatchOnScaleStartCallback(); - } - } - } - - void _startDragForPointer(int pointer) { - if (onDragStart != null) { - final Offset initialPosition = _initialPointerLocations[pointer]!; - final Drag? drag = invokeCallback('onDragStart', () { - return onDragStart!(initialPosition); - }); - _activeDrags[pointer] = drag; - } - } - - void _dispatchOnScaleStartCallback() { - if (onScaleStart != null) { - invokeCallback('onScaleStart', () { - onScaleStart!( - ScaleStartDetails( - focalPoint: _currentFocalPoint!, - localFocalPoint: _localFocalPoint, - pointerCount: pointerCount, - sourceTimeStamp: _initialScaleEventTimestamp, - ), - ); - }); - } - } - - double _computeRotationFactor() { - double factor = 0.0; - if (_initialLine != null && _currentLine != null) { - final double fx = _initialLine!.pointerStartLocation.dx; - final double fy = _initialLine!.pointerStartLocation.dy; - final double sx = _initialLine!.pointerEndLocation.dx; - final double sy = _initialLine!.pointerEndLocation.dy; - - final double nfx = _currentLine!.pointerStartLocation.dx; - final double nfy = _currentLine!.pointerStartLocation.dy; - final double nsx = _currentLine!.pointerEndLocation.dx; - final double nsy = _currentLine!.pointerEndLocation.dy; - - final double angle1 = math.atan2(fy - sy, fx - sx); - final double angle2 = math.atan2(nfy - nsy, nfx - nsx); - - factor = angle2 - angle1; - } - return factor; - } - - bool _isFlingGesture(Velocity velocity) { - final double speedSquared = velocity.pixelsPerSecond.distanceSquared; - return speedSquared > kMinFlingVelocity * kMinFlingVelocity; - } - - @override - void acceptGesture(int pointer) { - if (_state == _GestureState.possible) { - _state = _GestureState.started; - - // Start drag for this pointer - _startDragForPointer(pointer); - - // Start scale gesture if we have 2+ pointers - if (_pointerLocations.length >= 2 && !_scaleGestureActive) { - _scaleGestureActive = true; - _dispatchOnScaleStartCallback(); - } - - if (dragStartBehavior == DragStartBehavior.start) { - _initialFocalPoint = _currentFocalPoint!; - _initialSpan = _currentSpan; - _initialLine = _currentLine; - _initialHorizontalSpan = _currentHorizontalSpan; - _initialVerticalSpan = _currentVerticalSpan; - } - } - } - - @override - void rejectGesture(int pointer) { - final Drag? drag = _activeDrags[pointer]; - if (drag != null) { - drag.cancel(); - _activeDrags.remove(pointer); - } - - _pointerLocations.remove(pointer); - _initialPointerLocations.remove(pointer); - _lastPointerPositions.remove(pointer); - _pointerQueue.remove(pointer); - stopTrackingPointer(pointer); - } - - @override - void didStopTrackingLastPointer(int pointer) { - switch (_state) { - case _GestureState.possible: - resolve(GestureDisposition.rejected); - case _GestureState.ready: - assert(false); - case _GestureState.accepted: - break; - case _GestureState.started: - assert(false); - } - _state = _GestureState.ready; - _scaleGestureActive = false; - } - - @override - void dispose() { - _velocityTrackers.clear(); - for (final Drag? drag in _activeDrags.values) { - drag?.cancel(); - } - _activeDrags.clear(); - super.dispose(); - } - - @override - String get debugDescription => 'multi-drag-scale'; -} - -enum _GestureState { - ready, - possible, - accepted, - started, -} - -class _LineBetweenPointers { - _LineBetweenPointers({ - required this.pointerStartId, - required this.pointerStartLocation, - required this.pointerEndId, - required this.pointerEndLocation, - }); - - final int pointerStartId; - final Offset pointerStartLocation; - final int pointerEndId; - final Offset pointerEndLocation; -} diff --git a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart index e60d6946193..09e080afd83 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -62,26 +62,33 @@ mixin DragCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); - + final game = findRootGame()!; final scaleDispatcher = game.findByKey(const ScaleDispatcherKey()); final multiDragDispatcher = game.findByKey(const MultiDragDispatcherKey()); - final multiDragScaleDispatcher = game.findByKey(const MultiDragScaleDispatcherKey()); + 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 (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(); + (multiDragDispatcher as MultiDragDispatcher).markForRemoval(); return; } - + // If MultiDragDispatcher exists and no ScaleCallbacks, we're done - if (multiDragDispatcher != null) return; + if (multiDragDispatcher != null) { + return; + } if (scaleDispatcher == null && multiDragDispatcher == null) { // Check if component also has ScaleCallbacks @@ -99,7 +106,7 @@ mixin DragCallbacks on Component { final dispatcher = MultiDragScaleDispatcher(); game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); - //(scaleDispatcher as ScaleDispatcher).markForRemoval(); + (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 d9238140ca4..efd832908cf 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -1,6 +1,5 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; -import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart'; import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flutter/foundation.dart'; @@ -12,7 +11,6 @@ mixin ScaleCallbacks on Component { @mustCallSuper void onScaleStart(ScaleStartEvent event) { - debugPrint("ScaleCallbacks.onScaleStart called!"); _isScaling = true; } @@ -28,16 +26,22 @@ mixin ScaleCallbacks on Component { void onMount() { super.onMount(); // Skip if DragCallbacks will handle it - if (this is DragCallbacks) return; - + if (this is DragCallbacks) { + return; + } + final game = findRootGame()!; final scaleDispatcher = game.findByKey(const ScaleDispatcherKey()); final multiDragDispatcher = game.findByKey(const MultiDragDispatcherKey()); - final multiDragScaleDispatcher = game.findByKey(const MultiDragScaleDispatcherKey()); + final multiDragScaleDispatcher = game.findByKey( + const MultiDragScaleDispatcherKey(), + ); // If MultiDragScaleDispatcher exists, DragCallbacks already handled it - if (multiDragScaleDispatcher != null) return; - + if (multiDragScaleDispatcher != null) { + return; + } + if (scaleDispatcher == null && multiDragDispatcher == null) { final dispatcher = ScaleDispatcher(); game.registerKey(const ScaleDispatcherKey(), dispatcher); @@ -46,7 +50,7 @@ mixin ScaleCallbacks on Component { final dispatcher = MultiDragScaleDispatcher(); game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); - //(multiDragDispatcher as MultiDragDispatcher).markForRemoval(); + (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 515cc164be8..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 @@ -111,7 +111,7 @@ class MultiDragDispatcher extends Component implements MultiDragListener { @internal @override void handleDragStart(int pointerId, DragStartDetails details) { - if(_shouldBeRemoved){ + if (_shouldBeRemoved) { return; } final event = DragStartEvent(pointerId, game, details); @@ -141,15 +141,15 @@ class MultiDragDispatcher extends Component implements MultiDragListener { _tryRemoving(); } - void markForRemoval(){ + void markForRemoval() { _shouldBeRemoved = true; - _tryRemoving(); + _tryRemoving(); } - bool _tryRemoving(){ + bool _tryRemoving() { // there's no more fingers // that started dragging before _shouldBeRemoved flag was set to true. - if(_records.isEmpty && _shouldBeRemoved && isMounted){ + if (_records.isEmpty && _shouldBeRemoved && isMounted) { removeFromParent(); return true; } @@ -160,11 +160,10 @@ class MultiDragDispatcher extends Component implements MultiDragListener { @override void onMount() { - if(_tryRemoving()){ - return; - }; + if (_tryRemoving()) { + return; + } - debugPrint("mount multi drag gesture recognizer"); game.gestureDetectors.add( ImmediateMultiDragGestureRecognizer.new, (ImmediateMultiDragGestureRecognizer instance) { 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 b8756e4360b..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 @@ -106,15 +106,15 @@ class ScaleDispatcher extends Component implements ScaleListener { //#endregion - void markForRemoval(){ + void markForRemoval() { _shouldBeRemoved = true; - _tryRemoving(); + _tryRemoving(); } - bool _tryRemoving(){ + bool _tryRemoving() { // there's no more fingers // that started dragging before _shouldBeRemoved flag was set to true. - if(_records.isEmpty && _shouldBeRemoved && isMounted){ + if (_records.isEmpty && _shouldBeRemoved && isMounted) { removeFromParent(); return true; } @@ -123,8 +123,8 @@ class ScaleDispatcher extends Component implements ScaleListener { @override void onMount() { - if(_tryRemoving()){ - return; + if (_tryRemoving()) { + return; } game.gestureDetectors.add( ScaleGestureRecognizer.new, 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 index 4c298419e81..79f0b39a8b6 100644 --- 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 @@ -29,8 +29,6 @@ class MultiDragScaleDispatcher extends Component /// 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 @@ -97,6 +95,17 @@ class MultiDragScaleDispatcher extends Component }); } + @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 @@ -120,6 +129,13 @@ class MultiDragScaleDispatcher extends Component 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. @@ -218,9 +234,4 @@ class MultiDragScaleDispatcher extends Component @override GameRenderBox get renderBox => game.renderBox; - - @override - void handleDragCancel(int pointerId) { - // TODO: implement handleDragCancel - } } diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 45c7fff7310..6017a7da962 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -89,8 +89,9 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { ); _pointers[event.pointer] = state; GestureBinding.instance.pointerRouter.addRoute(event.pointer, _handleEvent); - state._setArenaEntry( - GestureBinding.instance.gestureArena.add(event.pointer, this), + state.arenaEntry = GestureBinding.instance.gestureArena.add( + event.pointer, + this, ); // Initialize scale tracking when first pointer is added @@ -105,7 +106,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { void _handleEvent(PointerEvent event) { assert(_pointers.containsKey(event.pointer)); - final _DragPointerState state = _pointers[event.pointer]!; + final state = _pointers[event.pointer]!; if (event is PointerMoveEvent) { state._move(event); @@ -199,14 +200,13 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { // End scale gesture if we drop below 2 pointers if (_scaleGestureActive && _pointers.length < 2) { if (onScaleEnd != null) { - final Velocity velocity = - _scaleVelocityTracker?.getVelocity() ?? Velocity.zero; + final velocity = _scaleVelocityTracker?.getVelocity() ?? Velocity.zero; if (_isFlingGesture(velocity)) { - final Offset pixelsPerSecond = velocity.pixelsPerSecond; + final pixelsPerSecond = velocity.pixelsPerSecond; if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) { - final Velocity clampedVelocity = Velocity( + final clampedVelocity = Velocity( pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity, @@ -256,11 +256,11 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { return; } - final double spanDelta = (_currentSpan - _initialSpan).abs(); - final double scaleFactor = _pointerScaleFactor; + final spanDelta = (_currentSpan - _initialSpan).abs(); + final scaleFactor = _pointerScaleFactor; // Get the kind from any pointer state - final PointerDeviceKind kind = _pointers.values.first.kind; + final kind = _pointers.values.first.kind; // If we detect a scale gesture, accept all pointer gestures if (spanDelta > computeScaleSlop(kind) || @@ -274,11 +274,11 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } void _update() { - final Offset? previousFocalPoint = _currentFocalPoint; + final previousFocalPoint = _currentFocalPoint; // Compute the focal point - Offset focalPoint = Offset.zero; - for (final _DragPointerState state in _pointers.values) { + var focalPoint = Offset.zero; + for (final state in _pointers.values) { focalPoint += state.currentPosition; } _currentFocalPoint = _pointers.isEmpty @@ -292,7 +292,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { ); _delta = Offset.zero; } else { - final Offset localPreviousFocalPoint = _localFocalPoint; + final localPreviousFocalPoint = _localFocalPoint; _localFocalPoint = PointerEvent.transformPosition( _lastTransform, _currentFocalPoint!, @@ -300,9 +300,9 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _delta = _localFocalPoint - localPreviousFocalPoint; } - final int count = _pointers.length; - Offset pointerFocalPoint = Offset.zero; - for (final _DragPointerState state in _pointers.values) { + final count = _pointers.length; + var pointerFocalPoint = Offset.zero; + for (final state in _pointers.values) { pointerFocalPoint += state.currentPosition; } if (count > 0) { @@ -310,10 +310,10 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } // Calculate span - double totalDeviation = 0.0; - double totalHorizontalDeviation = 0.0; - double totalVerticalDeviation = 0.0; - for (final _DragPointerState state in _pointers.values) { + 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(); @@ -326,8 +326,8 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } void _updateLines() { - final int count = _pointers.length; - final List pointerIds = _pointers.keys.toList(); + final count = _pointers.length; + final pointerIds = _pointers.keys.toList(); if (count < 2) { _initialLine = _currentLine; @@ -352,20 +352,20 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } double _computeRotationFactor() { - double factor = 0.0; + var factor = 0.0; if (_initialLine != null && _currentLine != null) { - final double fx = _initialLine!.pointerStartLocation.dx; - final double fy = _initialLine!.pointerStartLocation.dy; - final double sx = _initialLine!.pointerEndLocation.dx; - final double sy = _initialLine!.pointerEndLocation.dy; + final fx = _initialLine!.pointerStartLocation.dx; + final fy = _initialLine!.pointerStartLocation.dy; + final sx = _initialLine!.pointerEndLocation.dx; + final sy = _initialLine!.pointerEndLocation.dy; - final double nfx = _currentLine!.pointerStartLocation.dx; - final double nfy = _currentLine!.pointerStartLocation.dy; - final double nsx = _currentLine!.pointerEndLocation.dx; - final double nsy = _currentLine!.pointerEndLocation.dy; + final nfx = _currentLine!.pointerStartLocation.dx; + final nfy = _currentLine!.pointerStartLocation.dy; + final nsx = _currentLine!.pointerEndLocation.dx; + final nsy = _currentLine!.pointerEndLocation.dy; - final double angle1 = math.atan2(fy - sy, fx - sx); - final double angle2 = math.atan2(nfy - nsy, nfx - nsx); + final angle1 = math.atan2(fy - sy, fx - sx); + final angle2 = math.atan2(nfy - nsy, nfx - nsx); factor = angle2 - angle1; } @@ -373,7 +373,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } bool _isFlingGesture(Velocity velocity) { - final double speedSquared = velocity.pixelsPerSecond.distanceSquared; + final speedSquared = velocity.pixelsPerSecond.distanceSquared; return speedSquared > kMinFlingVelocity * kMinFlingVelocity; } @@ -390,7 +390,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { @override void acceptGesture(int pointer) { - final _DragPointerState? state = _pointers[pointer]; + final state = _pointers[pointer]; if (state == null) { return; // Already removed } @@ -399,7 +399,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { @override void rejectGesture(int pointer) { - final _DragPointerState? state = _pointers[pointer]; + final state = _pointers[pointer]; if (state != null) { state._rejected(); _removeState(pointer); @@ -416,8 +416,8 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { @override void dispose() { - final List pointers = _pointers.keys.toList(); - for (final int pointer in pointers) { + final pointers = _pointers.keys.toList(); + for (final pointer in pointers) { _removeState(pointer); } assert(_pointers.isEmpty); @@ -448,7 +448,7 @@ class _DragPointerState { Drag? _drag; bool _resolved = false; - void _setArenaEntry(GestureArenaEntry entry) { + set arenaEntry(GestureArenaEntry entry) { _arenaEntry = entry; } @@ -457,12 +457,13 @@ class _DragPointerState { velocityTracker.addPosition(event.timeStamp, event.position); } - final Offset delta = event.position - currentPosition; + final delta = event.position - currentPosition; currentPosition = event.position; if (!_resolved) { - // Check if we should resolve the gesture based on individual pointer movement - final double distance = (currentPosition - initialPosition).distance; + // 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); } 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 837dbe08486..8c801ee7937 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 @@ -1,18 +1,16 @@ -import 'dart:math'; - import 'package:flame/components.dart'; import 'package:flame/events.dart' hide PointerMoveEvent; import 'package:flame/game.dart'; -import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/gestures.dart' show PointerAddedEvent, kPrimaryButton; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('ScaleCallbacks', () { + group('ScaleAndDragCallbacks', () { testWithFlameGame( - 'make sure components can be added to a FlameGame', + '''make sure adding a component with both scale and drag mixins + adds a MultiDragScaleDispatcher''', (game) async { await game.add(_ScaleDragCallbacksComponent()); await game.ready(); @@ -20,60 +18,45 @@ void main() { }, ); }); - testWithFlameGame('scale drag event start', (game) async { - final component = _ScaleDragCallbacksComponent() - ..x = 10 - ..y = 10 - ..width = 10 - ..height = 10; - game.add(component); - await game.ready(); - - expect(game.children.whereType().length, 1); - game.firstChild()!.onScaleStart( - createScaleStartEvents( - game: game, - localFocalPoint: const Offset(12, 12), - focalPoint: const Offset(12, 12), - ), - ); - expect(component.containsLocalPoint(Vector2(10, 10)), false); - }); - testWithFlameGame('scale event start, update and end', (game) async { - final component = _ScaleDragCallbacksComponent() - ..x = 10 - ..y = 10 - ..width = 10 - ..height = 10; - await game.ensureAdd(component); - final dispatcher = game.firstChild()!; - - dispatcher.onScaleStart( - createScaleStartEvents( - game: game, - localFocalPoint: const Offset(12, 12), - focalPoint: const Offset(12, 12), - ), - ); - expect(component.scaleStartEvent, 1); - expect(component.scaleUpdateEvent, 0); - expect(component.scaleEndEvent, 0); - - dispatcher.onScaleUpdate( - createScaleUpdateEvents( - game: game, - localFocalPoint: const Offset(15, 15), - focalPoint: const Offset(15, 15), - ), - ); + testWithFlameGame( + '''scale event 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 dispatcher = game.firstChild()!; - expect(game.containsLocalPoint(Vector2(9, 9)), isTrue); - expect(component.scaleUpdateEvent, equals(1)); + dispatcher.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); - dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); - expect(component.scaleEndEvent, equals(1)); - }); + dispatcher.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)); + + dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + expect(component.scaleEndEvent, equals(1)); + }, + ); testWithFlameGame( 'scale event update not called without onScaleStart', @@ -153,7 +136,7 @@ void main() { ); testWithGame( - 'make sure the FlameGame can registers Scale Callbacks on itself', + 'make sure the FlameGame can registers Scale and Drag Callbacks on itself', _ScaleDragCallbacksGame.new, (game) async { await game.ready(); @@ -224,7 +207,8 @@ void main() { expect(component.isScaledStateChange, equals(2)); }, ); - group('HasScalableComponents', () { + + group('HasScaleAndDragMixins', () { testWidgets( 'scale event does not affect more than one component', (tester) async { @@ -328,169 +312,167 @@ void main() { }, ); - 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, - ), - ); + 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 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 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, - ); + 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(); + 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)); - }, - ); + 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 + '''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); + (tester) async { + final resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); - final dragComponent = _DragWithCallbacksComponent(); - await game.world.add(dragComponent); + final scaleComponent = _ScaleWithCallbacksComponent(); + await game.world.add(scaleComponent); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(Durations.short1); - await tester.pump(); - await tester.pump(); + final dragComponent = _DragWithCallbacksComponent(); + await game.world.add(dragComponent); - expect(game.children.toList()[2], isA()); - }, - ); + await tester.pump(); + await tester.pump(); + expect(game.children.toList()[1], isA()); + }, + ); - testWidgets( - '''adding scale component after drag + 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), - ); + (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(); + 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); - } + Future injectScale() async { + final scaleComponent = _ScaleWithCallbacksComponent(); + await game.world.add(scaleComponent); + await tester.pump(); + expect(dragComponent.isDragged, true); + } - final center = (game.canvasSize / 2).toOffset(); + final center = (game.canvasSize / 2).toOffset(); - await dragWithInjection( - tester, - center, - const Offset(20, 0), - const Duration(milliseconds: 200), - injectScale, // ajout à mi-chemin - ); - }, - ); + await dragWithInjection( + tester, + center, + const Offset(20, 0), + const Duration(milliseconds: 200), + injectScale, // ajout à mi-chemin + ); + }, + ); testWidgets( - '''adding drag component after scale + '''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), - ); + (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(); + 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); - } + 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 _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, - ); - }, - ); + final center = (game.canvasSize / 2).toOffset(); + await _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, + ); + }, + ); + }); } - - Future dragWithInjection( WidgetTester tester, Offset start, Offset delta, Duration duration, - Future Function() onHalfway, - {int steps = 20} -) async { + Future Function() onHalfway, { + int steps = 20, +}) async { final gesture = await tester.startGesture(start); final dt = duration ~/ steps; @@ -574,10 +556,10 @@ Future _zoomFrom( } class _ScaleDragCallbacksComponent extends PositionComponent - with ScaleCallbacks, DragCallbacks, _ScaleCounter, _DragCounter {} + with ScaleCallbacks, DragCallbacks, _ScaleCounter, _DragCounter {} class _ScaleDragCallbacksGame extends FlameGame - with ScaleCallbacks, DragCallbacks, _ScaleCounter, _DragCounter {} + with ScaleCallbacks, DragCallbacks, _ScaleCounter, _DragCounter {} class _SimpleScaleDragCallbacksComponent extends PositionComponent with ScaleCallbacks, DragCallbacks { @@ -732,7 +714,6 @@ mixin _ScaleCounter on ScaleCallbacks { _wasScaled = isScaling; } } - } class _DragWithCallbacksComponent extends PositionComponent with DragCallbacks { @@ -802,7 +783,6 @@ class _ScaleWithCallbacksComponent extends PositionComponent } } - // Source - https://stackoverflow.com/a/75171528 // Posted by Alexander // Retrieved 2025-11-19, License - CC BY-SA 4.0 From 38d5e8ca3f8bc16c10ce1b216f0c6e38858484b7 Mon Sep 17 00:00:00 2001 From: stilnat Date: Tue, 25 Nov 2025 00:16:35 +0100 Subject: [PATCH 37/87] make test include drag better --- .../scale_callbacks_test.dart | 2 +- .../scale_drag_callbacks_test.dart | 263 ++++++++++++++---- 2 files changed, 211 insertions(+), 54 deletions(-) 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..8ac89e1e902 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -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( 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 8c801ee7937..d9e51d9a6e6 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 @@ -20,7 +20,7 @@ void main() { }); testWithFlameGame( - '''scale event start, update and end on component + '''scale and drag events start, update and end on component with both scale and drag mixins ''', (game) async { final component = _ScaleDragCallbacksComponent() @@ -29,9 +29,10 @@ void main() { ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final scaleCallback = game.firstChild()!; + final dragCallback = game.firstChild()!; - dispatcher.onScaleStart( + scaleCallback.onScaleStart( createScaleStartEvents( game: game, localFocalPoint: const Offset(12, 12), @@ -42,7 +43,7 @@ void main() { expect(component.scaleUpdateEvent, 0); expect(component.scaleEndEvent, 0); - dispatcher.onScaleUpdate( + scaleCallback.onScaleUpdate( createScaleUpdateEvents( game: game, localFocalPoint: const Offset(15, 15), @@ -53,13 +54,38 @@ void main() { expect(game.containsLocalPoint(Vector2(9, 9)), isTrue); expect(component.scaleUpdateEvent, equals(1)); - dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + 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 event update not called without onScaleStart', + 'scale and drag events update not called without onStart', (game) async { final component = _ScaleDragCallbacksComponent() ..x = 10 @@ -79,10 +105,23 @@ void main() { ), ); 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 correctly registered handled event', (tester) async { + testWidgets('scale and drag correctly registered handled event', ( + tester, + ) async { final component = _ScaleDragCallbacksComponent() ..x = 100 ..y = 100 @@ -109,10 +148,15 @@ void main() { 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 outside of component is not registered as handled', + 'scale and outside of component is not registered as handled', (tester) async { final component = _ScaleDragCallbacksComponent()..size = Vector2.all(100); final game = FlameGame(children: [component]); @@ -132,6 +176,9 @@ void main() { 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)); }, ); @@ -146,7 +193,7 @@ void main() { ); testWidgets( - 'scale correctly registered handled event directly on FlameGame', + '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)); @@ -168,11 +215,15 @@ void main() { 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( - 'isScaled is changed', + 'isScaling and isDragged is changed', (tester) async { final component = _ScaleDragCallbacksComponent() ..size = Vector2.all(100) @@ -194,6 +245,7 @@ void main() { ); expect(component.isScaledStateChange, equals(2)); + expect(component.isDraggedStateChange, equals(2)); // Outside component await _zoomFrom( @@ -205,12 +257,13 @@ void main() { ); expect(component.isScaledStateChange, equals(2)); + expect(component.isDraggedStateChange, equals(2)); }, ); group('HasScaleAndDragMixins', () { testWidgets( - 'scale event does not affect more than one component', + 'scale and drag events does not affect more than one component', (tester) async { var nEvents = 0; final game = FlameGame( @@ -220,6 +273,9 @@ void main() { 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, @@ -240,14 +296,16 @@ void main() { ); testWidgets( - 'scale event can move outside the component bounds and still fire', + 'scale and drag event can move outside the component bounds and fire', (tester) async { - var nEvents = 0; + var nScaleEvents = 0; + var nDragEvents = 0; const intervals = 50; final component = _ScaleDragWithCallbacksComponent( size: Vector2.all(30), position: Vector2.all(100), - onScaleUpdate: (e) => nEvents++, + onScaleUpdate: (e) => nScaleEvents++, + onDragUpdate: (e) => nDragEvents++, ); final game = FlameGame( children: [component], @@ -264,53 +322,152 @@ void main() { const Duration(milliseconds: 300), intervals: intervals, ); - expect(nEvents, intervals * 2 + 2); + expect(nScaleEvents, intervals * 2 + 2); + expect(nDragEvents, intervals * 2 + 2); }, ); - }); - testWidgets( - 'scale event scale respects camera & zoom', - (tester) async { - final resolution = Vector2(80, 60); - final game = FlameGame( - camera: CameraComponent.withFixedResolution( - width: resolution.x, - height: resolution.y, - ), - ); - final scales = []; + 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; + 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(); + 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 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, - ); + 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)); - }, - ); + 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( From 7e65ee6f1b71f3cf4af422fd6a89e1e1ec45fd9c Mon Sep 17 00:00:00 2001 From: stilnat Date: Tue, 25 Nov 2025 00:44:44 +0100 Subject: [PATCH 38/87] factor test helper --- .../component_mixins/drag_callbacks_test.dart | 125 +----- .../component_mixins/input_test_helper.dart | 365 ++++++++++++++++ .../scale_callbacks_test.dart | 246 ++--------- .../scale_drag_callbacks_test.dart | 402 ++---------------- 4 files changed, 438 insertions(+), 700 deletions(-) create mode 100644 packages/flame/test/events/component_mixins/input_test_helper.dart 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..a60ae3923b1 --- /dev/null +++ b/packages/flame/test/events/component_mixins/input_test_helper.dart @@ -0,0 +1,365 @@ +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(); +} + +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 8ac89e1e902..6a30fd9359c 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 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 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 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 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 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 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), @@ -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 index d9e51d9a6e6..053d813ea9b 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 @@ -2,9 +2,9 @@ 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/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('ScaleAndDragCallbacks', () { @@ -12,7 +12,7 @@ void main() { '''make sure adding a component with both scale and drag mixins adds a MultiDragScaleDispatcher''', (game) async { - await game.add(_ScaleDragCallbacksComponent()); + await game.add(ScaleDragCallbacksComponent()); await game.ready(); expect(game.children.toList()[2], isA()); }, @@ -23,7 +23,7 @@ void main() { '''scale and drag events start, update and end on component with both scale and drag mixins ''', (game) async { - final component = _ScaleDragCallbacksComponent() + final component = ScaleDragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -87,7 +87,7 @@ void main() { testWithFlameGame( 'scale and drag events update not called without onStart', (game) async { - final component = _ScaleDragCallbacksComponent() + final component = ScaleDragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -122,7 +122,7 @@ void main() { testWidgets('scale and drag correctly registered handled event', ( tester, ) async { - final component = _ScaleDragCallbacksComponent() + final component = ScaleDragCallbacksComponent() ..x = 100 ..y = 100 ..width = 150 @@ -132,7 +132,7 @@ void main() { await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); - await _zoomFrom( + await zoomFrom( tester, startLocation1: const Offset(180, 150), offset1: const Offset(15, 2), @@ -158,14 +158,14 @@ void main() { testWidgets( 'scale and outside of component is not registered as handled', (tester) async { - final component = _ScaleDragCallbacksComponent()..size = Vector2.all(100); + 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 _zoomFrom( + await zoomFrom( tester, startLocation1: const Offset(250, 200), offset1: const Offset(15, 2), @@ -184,7 +184,7 @@ void main() { testWithGame( 'make sure the FlameGame can registers Scale and Drag Callbacks on itself', - _ScaleDragCallbacksGame.new, + ScaleDragCallbacksGame.new, (game) async { await game.ready(); expect(game.children.length, equals(3)); @@ -195,7 +195,7 @@ void main() { testWidgets( 'scale and drag correctly registered handled event directly on FlameGame', (tester) async { - final game = _ScaleDragCallbacksGame()..onGameResize(Vector2.all(300)); + final game = ScaleDragCallbacksGame()..onGameResize(Vector2.all(300)); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); @@ -204,7 +204,7 @@ void main() { await tester.pump(); await tester.pump(); - await _zoomFrom( + await zoomFrom( tester, startLocation1: const Offset(50, 100), offset1: const Offset(15, 2), @@ -225,7 +225,7 @@ void main() { testWidgets( 'isScaling and isDragged is changed', (tester) async { - final component = _ScaleDragCallbacksComponent() + final component = ScaleDragCallbacksComponent() ..size = Vector2.all(100) ..x = 100 ..y = 100; @@ -236,7 +236,7 @@ void main() { await tester.pump(); // Inside component - await _zoomFrom( + await zoomFrom( tester, startLocation1: const Offset(180, 100), offset1: const Offset(15, 2), @@ -248,7 +248,7 @@ void main() { expect(component.isDraggedStateChange, equals(2)); // Outside component - await _zoomFrom( + await zoomFrom( tester, startLocation1: const Offset(330, 300), offset1: const Offset(15, 2), @@ -268,7 +268,7 @@ void main() { var nEvents = 0; final game = FlameGame( children: [ - _ScaleDragWithCallbacksComponent( + ScaleDragWithCallbacksComponent( size: Vector2.all(100), onScaleStart: (e) => nEvents++, onScaleUpdate: (e) => nEvents++, @@ -277,14 +277,14 @@ void main() { onDragEnd: (e) => nEvents++, onDragUpdate: (e) => nEvents++, ), - _SimpleScaleDragCallbacksComponent(size: Vector2.all(200)) + SimpleScaleDragCallbacksComponent(size: Vector2.all(200)) ..priority = 10, ], ); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(); await tester.pump(); - await _zoomFrom( + await zoomFrom( tester, startLocation1: const Offset(80, 50), offset1: const Offset(15, 2), @@ -301,7 +301,7 @@ void main() { var nScaleEvents = 0; var nDragEvents = 0; const intervals = 50; - final component = _ScaleDragWithCallbacksComponent( + final component = ScaleDragWithCallbacksComponent( size: Vector2.all(30), position: Vector2.all(100), onScaleUpdate: (e) => nScaleEvents++, @@ -314,7 +314,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), @@ -342,7 +342,7 @@ void main() { game.camera.viewfinder.zoom = 3; await game.world.add( - _ScaleDragWithCallbacksComponent( + ScaleDragWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), onScaleUpdate: (event) { @@ -357,7 +357,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), @@ -387,7 +387,7 @@ void main() { final deltas = []; await game.world.add( - _ScaleDragWithCallbacksComponent( + ScaleDragWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), onDragUpdate: (event) => deltas.add(event.localDelta), @@ -424,7 +424,7 @@ void main() { final deltas = []; await game.world.add( - _ScaleDragWithCallbacksComponent( + ScaleDragWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), onDragUpdate: (event) => deltas.add(event.localDelta), @@ -481,7 +481,7 @@ void main() { ), ); - final component = _ScaleDragWithCallbacksComponent( + final component = ScaleDragWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), ); @@ -493,7 +493,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), @@ -526,12 +526,12 @@ void main() { ), ); - final scaleComponent = _ScaleWithCallbacksComponent(); + final scaleComponent = ScaleWithCallbacksComponent(); await game.world.add(scaleComponent); await tester.pumpWidget(GameWidget(game: game)); await tester.pump(Durations.short1); - final dragComponent = _DragWithCallbacksComponent(); + final dragComponent = DragWithCallbacksComponent(); await game.world.add(dragComponent); await tester.pump(); @@ -551,7 +551,7 @@ void main() { height: resolution.y, ), ); - final dragComponent = _DragWithCallbacksComponent( + final dragComponent = DragWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), ); @@ -561,7 +561,7 @@ void main() { await tester.pump(); Future injectScale() async { - final scaleComponent = _ScaleWithCallbacksComponent(); + final scaleComponent = ScaleWithCallbacksComponent(); await game.world.add(scaleComponent); await tester.pump(); expect(dragComponent.isDragged, true); @@ -590,7 +590,7 @@ void main() { height: resolution.y, ), ); - final scaleComponent = _ScaleWithCallbacksComponent( + final scaleComponent = ScaleWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), ); @@ -600,7 +600,7 @@ void main() { await tester.pump(); Future injectDrag() async { - final dragComponent = _DragWithCallbacksComponent(); + final dragComponent = DragWithCallbacksComponent(); await game.world.add(dragComponent); await tester.pump(); expect(scaleComponent.isScaling, true); @@ -687,343 +687,3 @@ Future _zoomFromWithInjection( await tester.pump(); } - -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(); -} - -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); - } -} - -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); - } -} - -// 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); - }); - } -} From be53a17259ee9a225e4765c4a8ed470bcf93fdf6 Mon Sep 17 00:00:00 2001 From: stilnat Date: Tue, 25 Nov 2025 00:59:19 +0100 Subject: [PATCH 39/87] rename scale example to scale drag --- .../stories/input/{scale_example.dart => scale_drag_example.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/lib/stories/input/{scale_example.dart => scale_drag_example.dart} (100%) diff --git a/examples/lib/stories/input/scale_example.dart b/examples/lib/stories/input/scale_drag_example.dart similarity index 100% rename from examples/lib/stories/input/scale_example.dart rename to examples/lib/stories/input/scale_drag_example.dart From 51b23086581faad5218781eef117170ba4a75c71 Mon Sep 17 00:00:00 2001 From: stilnat Date: Tue, 25 Nov 2025 01:01:14 +0100 Subject: [PATCH 40/87] remove french --- .../events/component_mixins/scale_drag_callbacks_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 053d813ea9b..3a2bef35026 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 @@ -574,7 +574,7 @@ void main() { center, const Offset(20, 0), const Duration(milliseconds: 200), - injectScale, // ajout à mi-chemin + injectScale, ); }, ); @@ -635,7 +635,6 @@ Future dragWithInjection( for (var i = 0; i < steps; i++) { if (i == steps ~/ 2) { - // On est au milieu : injecte ton scale component await onHalfway(); } From 31813e501cb5214cbd7388990657fbd320cb7b1b Mon Sep 17 00:00:00 2001 From: stilnat Date: Tue, 25 Nov 2025 01:07:21 +0100 Subject: [PATCH 41/87] make consistent extension --- .../component_mixins/input_test_helper.dart | 73 +++++++++++++++- .../scale_drag_callbacks_test.dart | 83 ++----------------- 2 files changed, 79 insertions(+), 77 deletions(-) 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 a60ae3923b1..d62ce03f55c 100644 --- a/packages/flame/test/events/component_mixins/input_test_helper.dart +++ b/packages/flame/test/events/component_mixins/input_test_helper.dart @@ -318,9 +318,7 @@ extension ZoomTesting on WidgetTester { await handlePointerEventRecord(records); }); } -} - -Future zoomFrom( + Future zoomFrom( WidgetTester tester, { required Offset startLocation1, required Offset offset1, @@ -344,6 +342,75 @@ Future zoomFrom( 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 {} 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 3a2bef35026..7275658582e 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 @@ -132,7 +132,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), @@ -165,7 +165,7 @@ void main() { await tester.pump(); expect(component.isMounted, isTrue); - await zoomFrom( + await tester.zoomFrom( tester, startLocation1: const Offset(250, 200), offset1: const Offset(15, 2), @@ -204,7 +204,7 @@ void main() { await tester.pump(); await tester.pump(); - await zoomFrom( + await tester.zoomFrom( tester, startLocation1: const Offset(50, 100), offset1: const Offset(15, 2), @@ -236,7 +236,7 @@ void main() { await tester.pump(); // Inside component - await zoomFrom( + await tester.zoomFrom( tester, startLocation1: const Offset(180, 100), offset1: const Offset(15, 2), @@ -248,7 +248,7 @@ void main() { expect(component.isDraggedStateChange, equals(2)); // Outside component - await zoomFrom( + await tester.zoomFrom( tester, startLocation1: const Offset(330, 300), offset1: const Offset(15, 2), @@ -284,7 +284,7 @@ void main() { 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), @@ -569,7 +569,7 @@ void main() { final center = (game.canvasSize / 2).toOffset(); - await dragWithInjection( + await tester.dragWithInjection( tester, center, const Offset(20, 0), @@ -608,7 +608,7 @@ void main() { final center = (game.canvasSize / 2).toOffset(); - await _zoomFromWithInjection( + await tester.zoomFromWithInjection( tester, startLocation1: center.translate(-3, 0), offset1: const Offset(15, 2), @@ -620,69 +620,4 @@ void main() { }, ); }); -} - -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(); -} +} \ No newline at end of file From 5d585873632f67b5384ba869b46af661ab023d7c Mon Sep 17 00:00:00 2001 From: stilnat Date: Tue, 25 Nov 2025 01:10:59 +0100 Subject: [PATCH 42/87] update doc --- doc/flame/inputs/scale_events.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 53a553bd4d6..d408798dbf8 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -172,15 +172,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. From b34e0caa526d776ed124b3760ba6255d242ec43d Mon Sep 17 00:00:00 2001 From: stilnat Date: Tue, 25 Nov 2025 01:20:29 +0100 Subject: [PATCH 43/87] fix small stuff --- .../component_mixins/scale_callbacks_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 6a30fd9359c..e4c2ef25559 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -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), @@ -136,7 +136,7 @@ void main() { await tester.pump(); expect(component.isMounted, isTrue); - await zoomFrom( + await tester.zoomFrom( tester, startLocation1: const Offset(250, 200), offset1: const Offset(15, 2), @@ -170,7 +170,7 @@ void main() { 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), @@ -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), @@ -240,7 +240,7 @@ void main() { 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), From 233d99119689dc90100f0c8b6389dbc21e49cab8 Mon Sep 17 00:00:00 2001 From: stilnat Date: Thu, 27 Nov 2025 14:53:23 +0100 Subject: [PATCH 44/87] Update doc/flame/inputs/scale_events.md Co-authored-by: Lukas Klingsbo --- doc/flame/inputs/scale_events.md | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index d408798dbf8..aaf2fef944d 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -3,7 +3,6 @@ **Scale events** occur when the user moves two fingers in a pinch in, or in a pinch out move. Only one single scale gesture can occur at the same time. - For those components that you want to respond to scale events, add the `ScaleCallbacks` mixin. - This mixin adds three overridable methods to your component: `onScaleStart`, `onScaleUpdate`, From b3c4b5be4e4c4bf00990ed23947e3682373ccdee Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 2 Mar 2026 22:12:00 +0100 Subject: [PATCH 45/87] Fix bugs, cleanup, and polish MultiDragScale recognizer PR - Add removal guard in ScaleDispatcher.handleScaleStart to reject new gestures after markForRemoval() - Add missing unregisterKey call in ScaleDispatcher.onRemove - Fix comment typos referencing wrong class name in scale_drag_dispatcher - Remove duplicate focal point computation in recognizer _update() - Convert test helper methods to proper extension methods and fix call-sites --- .../flame_game_mixins/scale_dispatcher.dart | 2 + .../scale_drag_dispatcher.dart | 10 +- .../events/multi_drag_scale_recognizer.dart | 19 +-- .../component_mixins/input_test_helper.dart | 150 +++++++++--------- .../scale_callbacks_test.dart | 6 - .../scale_drag_callbacks_test.dart | 8 - 6 files changed, 86 insertions(+), 109 deletions(-) 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 ffd7a417d28..66732e8d85e 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 @@ -88,6 +88,7 @@ class ScaleDispatcher extends Component implements ScaleListener { @internal @override void handleScaleStart(ScaleStartDetails details) { + if (_shouldBeRemoved) return; onScaleStart(ScaleStartEvent(0, game, details)); } @@ -141,6 +142,7 @@ class ScaleDispatcher extends Component implements ScaleListener { @override void onRemove() { game.gestureDetectors.remove(); + game.unregisterKey(const ScaleDispatcherKey()); super.onRemove(); } } 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 index 79f0b39a8b6..c45b61859d6 100644 --- 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 @@ -13,17 +13,17 @@ class MultiDragScaleDispatcherKey implements ComponentKey { const MultiDragScaleDispatcherKey(); @override - int get hashCode => 91604875; // 'MultiDragDispatcherKey' as hashCode + int get hashCode => 91604875; // 'MultiDragScaleDispatcherKey' 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. +/// **MultiDragScaleDispatcher** facilitates dispatching of both drag and scale +/// events to the [DragCallbacks] and [ScaleCallbacks] components in the +/// component tree. It will be attached to the [FlameGame] instance +/// automatically when both callback types are needed. class MultiDragScaleDispatcher extends Component implements MultiDragListener, ScaleListener { /// The record of all components currently being touched. diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 6017a7da962..a3916b3d745 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -300,26 +300,19 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _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 + // Calculate span using the already-computed focal point var totalDeviation = 0.0; var totalHorizontalDeviation = 0.0; var totalVerticalDeviation = 0.0; for (final state in _pointers.values) { - totalDeviation += (pointerFocalPoint - state.currentPosition).distance; + totalDeviation += + (_currentFocalPoint! - state.currentPosition).distance; totalHorizontalDeviation += - (pointerFocalPoint.dx - state.currentPosition.dx).abs(); + (_currentFocalPoint!.dx - state.currentPosition.dx).abs(); totalVerticalDeviation += - (pointerFocalPoint.dy - state.currentPosition.dy).abs(); + (_currentFocalPoint!.dy - state.currentPosition.dy).abs(); } + final count = _pointers.length; _currentSpan = count > 0 ? totalDeviation / count : 0.0; _currentHorizontalSpan = count > 0 ? totalHorizontalDeviation / count : 0.0; _currentVerticalSpan = count > 0 ? totalVerticalDeviation / count : 0.0; 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 d62ce03f55c..9b20af1ba1e 100644 --- a/packages/flame/test/events/component_mixins/input_test_helper.dart +++ b/packages/flame/test/events/component_mixins/input_test_helper.dart @@ -318,95 +318,91 @@ extension ZoomTesting on WidgetTester { 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 zoomFrom({ + required Offset startLocation1, + required Offset offset1, + required Offset startLocation2, + required Offset offset2, + }) async { + // Start two gestures on opposite sides of that center + final gesture1 = await startGesture(startLocation1); + final gesture2 = await startGesture(startLocation2); + + await pump(); + + await gesture1.moveBy(offset1); + await gesture2.moveBy(offset2); + await pump(); + + // release fingers + await gesture1.up(); + await gesture2.up(); + + await pump(); + } -Future dragWithInjection( - 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(); + Future dragWithInjection( + Offset start, + Offset delta, + Duration duration, + Future Function() onHalfway, { + int steps = 20, + }) async { + final gesture = await startGesture(start); + final dt = duration ~/ steps; + + for (var i = 0; i < steps; i++) { + if (i == steps ~/ 2) { + await onHalfway(); + } + + final t = (i + 1) / steps; + await gesture.moveTo(start + delta * t); + await pump(dt); } - final t = (i + 1) / steps; - await gesture.moveTo(start + delta * t); - await tester.pump(dt); + await gesture.up(); + await pump(); } - await gesture.up(); - await tester.pump(); -} + Future zoomFromWithInjection({ + required Offset startLocation1, + required Offset offset1, + required Offset startLocation2, + required Offset offset2, + required Duration duration, + required Future Function() onHalfway, + int steps = 20, + }) async { + // Start both fingers + final gesture1 = await startGesture(startLocation1); + final gesture2 = await startGesture(startLocation2); -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(); - } + await pump(); - final t = (i + 1) / steps; + final dt = duration ~/ steps; - await gesture1.moveTo(startLocation1 + offset1 * t); - await gesture2.moveTo(startLocation2 + offset2 * t); + for (var i = 0; i < steps; i++) { + // Inject custom logic at halfway + if (i == steps ~/ 2) { + await onHalfway(); + await pump(); + } - await tester.pump(dt); - } + final t = (i + 1) / steps; - // Release both gestures - await gesture1.up(); - await gesture2.up(); + await gesture1.moveTo(startLocation1 + offset1 * t); + await gesture2.moveTo(startLocation2 + offset2 * t); - await tester.pump(); -} + await pump(dt); + } + // Release both gestures + await gesture1.up(); + await gesture2.up(); + + await pump(); + } } 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 e4c2ef25559..643ad37aaaf 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -111,7 +111,6 @@ void main() { await tester.pump(); await tester.zoomFrom( - tester, startLocation1: const Offset(180, 150), offset1: const Offset(15, 2), startLocation2: const Offset(120, 150), @@ -137,7 +136,6 @@ void main() { expect(component.isMounted, isTrue); await tester.zoomFrom( - tester, startLocation1: const Offset(250, 200), offset1: const Offset(15, 2), startLocation2: const Offset(150, 200), @@ -171,7 +169,6 @@ void main() { expect(game.isMounted, isTrue); await tester.zoomFrom( - tester, startLocation1: const Offset(50, 100), offset1: const Offset(15, 2), startLocation2: const Offset(150, 100), @@ -199,7 +196,6 @@ void main() { // Inside component await tester.zoomFrom( - tester, startLocation1: const Offset(180, 100), offset1: const Offset(15, 2), startLocation2: const Offset(120, 100), @@ -210,7 +206,6 @@ void main() { // Outside component await tester.zoomFrom( - tester, startLocation1: const Offset(330, 300), offset1: const Offset(15, 2), startLocation2: const Offset(270, 300), @@ -241,7 +236,6 @@ void main() { 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), 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 7275658582e..e3385bc4982 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 @@ -133,7 +133,6 @@ void main() { await tester.pump(); await tester.zoomFrom( - tester, startLocation1: const Offset(180, 150), offset1: const Offset(15, 2), startLocation2: const Offset(120, 150), @@ -166,7 +165,6 @@ void main() { expect(component.isMounted, isTrue); await tester.zoomFrom( - tester, startLocation1: const Offset(250, 200), offset1: const Offset(15, 2), startLocation2: const Offset(150, 200), @@ -205,7 +203,6 @@ void main() { await tester.pump(); await tester.zoomFrom( - tester, startLocation1: const Offset(50, 100), offset1: const Offset(15, 2), startLocation2: const Offset(150, 100), @@ -237,7 +234,6 @@ void main() { // Inside component await tester.zoomFrom( - tester, startLocation1: const Offset(180, 100), offset1: const Offset(15, 2), startLocation2: const Offset(120, 100), @@ -249,7 +245,6 @@ void main() { // Outside component await tester.zoomFrom( - tester, startLocation1: const Offset(330, 300), offset1: const Offset(15, 2), startLocation2: const Offset(270, 300), @@ -285,7 +280,6 @@ void main() { 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), @@ -570,7 +564,6 @@ void main() { final center = (game.canvasSize / 2).toOffset(); await tester.dragWithInjection( - tester, center, const Offset(20, 0), const Duration(milliseconds: 200), @@ -609,7 +602,6 @@ void main() { 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), From b6e089e2cb11e74a51cb47acb86fca2862b9a2f4 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 2 Mar 2026 22:49:05 +0100 Subject: [PATCH 46/87] test: Add dispatcher lifecycle and upgrade path tests Cover markForRemoval guard, deferred removal after active gestures, unregisterKey re-creation, and ScaleDispatcher/MultiDragDispatcher upgrade to MultiDragScaleDispatcher. --- .../component_mixins/drag_callbacks_test.dart | 48 ++++++++++++++ .../scale_callbacks_test.dart | 62 +++++++++++++++++++ .../scale_drag_callbacks_test.dart | 56 +++++++++++++++++ 3 files changed, 166 insertions(+) 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 9fe520824e6..796b521aa09 100644 --- a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart @@ -394,4 +394,52 @@ void main() { expect(totalDelta, Vector2(16, 0)); }, ); + + group('MultiDragDispatcher lifecycle', () { + testWithFlameGame( + 'rejects new gestures after markForRemoval', + (game) async { + final component = DragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.markForRemoval(); + dispatcher.handleDragStart( + 1, + DragStartDetails(globalPosition: const Offset(12, 12)), + ); + expect(component.dragStartEvent, 0); + }, + ); + + testWithFlameGame( + 'removes itself after last gesture ends when marked', + (game) async { + final component = DragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.handleDragStart( + 1, + DragStartDetails(globalPosition: const Offset(12, 12)), + ); + expect(component.dragStartEvent, 1); + + dispatcher.markForRemoval(); + expect(dispatcher.isMounted, isTrue); + + dispatcher.handleDragEnd(1, DragEndDetails()); + game.update(0); + expect(game.children.whereType().length, 0); + }, + ); + }); } 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 643ad37aaaf..c500ff8b23e 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -369,4 +369,66 @@ void main() { } }, ); + + group('ScaleDispatcher lifecycle', () { + testWithFlameGame( + 'rejects new gestures after markForRemoval', + (game) async { + final component = ScaleCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.markForRemoval(); + dispatcher.handleScaleStart( + ScaleStartDetails(focalPoint: const Offset(12, 12)), + ); + expect(component.scaleStartEvent, 0); + }, + ); + + testWithFlameGame( + 'removes itself after last gesture ends when marked', + (game) async { + final component = ScaleCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.handleScaleStart( + ScaleStartDetails(focalPoint: const Offset(12, 12)), + ); + expect(component.scaleStartEvent, 1); + + dispatcher.markForRemoval(); + expect(dispatcher.isMounted, isTrue); + + dispatcher.handleScaleEnd(ScaleEndDetails()); + game.update(0); + expect(game.children.whereType().length, 0); + }, + ); + + testWithFlameGame( + 'can be recreated after removal (unregisterKey works)', + (game) async { + await game.ensureAdd(ScaleCallbacksComponent()); + expect(game.children.whereType().length, 1); + + final dispatcher = game.firstChild()!; + dispatcher.markForRemoval(); + game.update(0); + expect(game.children.whereType().length, 0); + + await game.ensureAdd(ScaleCallbacksComponent()); + expect(game.children.whereType().length, 1); + }, + ); + }); } 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 e3385bc4982..c1a80bde417 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 @@ -1,6 +1,7 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart' hide PointerMoveEvent; import 'package:flame/game.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -611,5 +612,60 @@ void main() { ); }, ); + + testWithFlameGame( + 'upgrade from ScaleDispatcher to MultiDragScaleDispatcher ' + 'marks old for removal', + (game) async { + await game.ensureAdd(ScaleCallbacksComponent()); + expect(game.children.whereType().length, 1); + + await game.ensureAdd(DragCallbacksComponent()); + game.update(0); + + expect( + game.children.whereType().length, + 1, + ); + expect(game.children.whereType().length, 0); + }, + ); + + testWithFlameGame( + 'upgrade from MultiDragDispatcher to MultiDragScaleDispatcher ' + 'marks old for removal', + (game) async { + await game.ensureAdd(DragCallbacksComponent()); + expect(game.children.whereType().length, 1); + + await game.ensureAdd(ScaleCallbacksComponent()); + game.update(0); + + expect( + game.children.whereType().length, + 1, + ); + expect(game.children.whereType().length, 0); + }, + ); + + testWithFlameGame( + 'adding ScaleCallbacks after DragCallbacks creates ' + 'MultiDragScaleDispatcher with only one dispatcher', + (game) async { + await game.ensureAdd(DragCallbacksComponent()); + expect(game.children.whereType().length, 1); + + await game.ensureAdd(ScaleCallbacksComponent()); + game.update(0); + + expect( + game.children.whereType().length, + 1, + ); + expect(game.children.whereType().length, 0); + expect(game.children.whereType().length, 0); + }, + ); }); } \ No newline at end of file From 7d260b32e2ffb37348d7250724a0a0d6c3717cf2 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 2 Mar 2026 22:54:11 +0100 Subject: [PATCH 47/87] feat: Add dynamic scale & drag example Interactive example that lets users spawn drag-only, scale-only, or combined components at runtime using buttons. Useful for verifying that mixing different interaction types works seamlessly. --- .../input/dynamic_scale_drag_example.dart | 328 ++++++++++++++++++ examples/lib/stories/input/input.dart | 7 + 2 files changed, 335 insertions(+) create mode 100644 examples/lib/stories/input/dynamic_scale_drag_example.dart diff --git a/examples/lib/stories/input/dynamic_scale_drag_example.dart b/examples/lib/stories/input/dynamic_scale_drag_example.dart new file mode 100644 index 00000000000..f9c661cbac5 --- /dev/null +++ b/examples/lib/stories/input/dynamic_scale_drag_example.dart @@ -0,0 +1,328 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart' hide PointerMoveEvent; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart' hide PointerMoveEvent; + +class DynamicScaleDragExample extends FlameGame { + static const String description = ''' + Demonstrates dynamically adding draggable and scalable components at + runtime. Use the buttons to spawn components with different interaction + types: drag-only (green), scale-only (blue), or both (red). + + Drag components by touching and moving. Scale components by pinching + with two fingers. Components with both mixins support either gesture. + The system seamlessly handles mixing different interaction types. + '''; + + late TextComponent dispatcherLabel; + + @override + Future onLoad() async { + dispatcherLabel = TextComponent( + text: 'Dispatcher: none', + textRenderer: TextPaint( + style: const TextStyle(fontSize: 18, color: Colors.white), + ), + position: Vector2(10, 10), + ); + camera.viewport.add(dispatcherLabel); + + camera.viewport.add( + _Button( + text: '+ Drag', + position: Vector2(10, 40), + color: Colors.green, + onPressed: _addDragComponent, + ), + ); + camera.viewport.add( + _Button( + text: '+ Scale', + position: Vector2(120, 40), + color: Colors.blue, + onPressed: _addScaleComponent, + ), + ); + camera.viewport.add( + _Button( + text: '+ Both', + position: Vector2(230, 40), + color: Colors.red, + onPressed: _addBothComponent, + ), + ); + camera.viewport.add( + _Button( + text: 'Clear', + position: Vector2(340, 40), + color: Colors.grey, + onPressed: _clearComponents, + ), + ); + } + + int _nextId = 1; + + void _addDragComponent() { + final pos = _randomWorldPosition(); + world.add( + _DragBox( + label: 'Drag ${_nextId++}', + position: pos, + color: Colors.green, + ), + ); + } + + void _addScaleComponent() { + final pos = _randomWorldPosition(); + world.add( + _ScaleBox( + label: 'Scale ${_nextId++}', + position: pos, + color: Colors.blue, + ), + ); + } + + void _addBothComponent() { + final pos = _randomWorldPosition(); + world.add( + _DragScaleBox( + label: 'Both ${_nextId++}', + position: pos, + color: Colors.red, + ), + ); + } + + void _clearComponents() { + world.removeAll( + world.children.where( + (c) => c is _DragBox || c is _ScaleBox || c is _DragScaleBox, + ), + ); + } + + Vector2 _randomWorldPosition() { + final rng = Random(); + return Vector2( + (rng.nextDouble() - 0.5) * 300, + (rng.nextDouble() - 0.5) * 200, + ); + } + + @override + void update(double dt) { + super.update(dt); + final dispatchers = []; + if (children.whereType().isNotEmpty) { + dispatchers.add('MultiDragScaleDispatcher'); + } + if (children.whereType().isNotEmpty) { + dispatchers.add('MultiDragDispatcher'); + } + // ScaleDispatcher is not publicly exported, so detect it by exclusion. + final otherDispatchers = children.where( + (c) => c is! MultiDragScaleDispatcher && c is! MultiDragDispatcher, + ); + for (final c in otherDispatchers) { + final name = c.runtimeType.toString(); + if (name == 'ScaleDispatcher') { + dispatchers.add('ScaleDispatcher'); + } + } + dispatcherLabel.text = + 'Dispatcher: ${dispatchers.isEmpty ? 'none' : dispatchers.join(', ')}'; + } +} + +class _Button extends PositionComponent with TapCallbacks { + _Button({ + required String text, + required super.position, + required Color color, + required VoidCallback onPressed, + }) : _color = color, + _onPressed = onPressed, + _text = text, + super(size: Vector2(100, 30)); + + final Color _color; + final VoidCallback _onPressed; + final String _text; + + @override + Future onLoad() async { + add( + TextComponent( + text: _text, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 16, color: Colors.white), + ), + position: size / 2, + anchor: Anchor.center, + ), + ); + } + + @override + void render(Canvas canvas) { + canvas.drawRRect( + RRect.fromRectAndRadius(size.toRect(), const Radius.circular(6)), + Paint()..color = _color, + ); + } + + @override + void onTapUp(TapUpEvent event) { + _onPressed(); + } +} + +/// A rectangle that only responds to drag. +class _DragBox extends RectangleComponent + with DragCallbacks, HasGameReference { + _DragBox({ + required String label, + required Vector2 position, + required Color color, + }) : _label = label, + super( + position: position, + size: Vector2.all(120), + anchor: Anchor.center, + paint: Paint()..color = color, + ); + + final String _label; + + @override + Future onLoad() async { + add( + TextComponent( + text: _label, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 18, color: Colors.white), + ), + position: size / 2, + anchor: Anchor.center, + ), + ); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + position += event.localDelta; + } +} + +/// A rectangle that only responds to scale (pinch/zoom). +class _ScaleBox extends RectangleComponent with ScaleCallbacks { + _ScaleBox({ + required String label, + required Vector2 position, + required Color color, + }) : _label = label, + super( + position: position, + size: Vector2.all(120), + anchor: Anchor.center, + paint: Paint()..color = color, + ); + + final String _label; + double _initialAngle = 0; + double _lastScale = 1.0; + + @override + Future onLoad() async { + add( + TextComponent( + text: _label, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 18, color: Colors.white), + ), + position: size / 2, + anchor: Anchor.center, + ), + ); + } + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + _initialAngle = angle; + _lastScale = 1.0; + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + angle = _initialAngle + event.rotation; + if (_lastScale != 0) { + final delta = event.scale / _lastScale; + scale *= sqrt(delta); + scale.clamp(Vector2.all(0.5), Vector2.all(3)); + } + _lastScale = event.scale; + } +} + +/// A rectangle that responds to both drag and scale. +class _DragScaleBox extends RectangleComponent + with ScaleCallbacks, DragCallbacks, HasGameReference { + _DragScaleBox({ + required String label, + required Vector2 position, + required Color color, + }) : _label = label, + super( + position: position, + size: Vector2.all(120), + anchor: Anchor.center, + paint: Paint()..color = color, + ); + + final String _label; + double _initialAngle = 0; + double _lastScale = 1.0; + + @override + Future onLoad() async { + add( + TextComponent( + text: _label, + textRenderer: TextPaint( + style: const TextStyle(fontSize: 18, color: Colors.white), + ), + position: size / 2, + anchor: Anchor.center, + ), + ); + } + + @override + void onDragUpdate(DragUpdateEvent event) { + position += event.localDelta; + } + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + _initialAngle = angle; + _lastScale = 1.0; + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + angle = _initialAngle + event.rotation; + if (_lastScale != 0) { + final delta = event.scale / _lastScale; + scale *= sqrt(delta); + scale.clamp(Vector2.all(0.5), Vector2.all(3)); + } + _lastScale = event.scale; + } +} diff --git a/examples/lib/stories/input/input.dart b/examples/lib/stories/input/input.dart index d2dae7a1ace..de83ec60c28 100644 --- a/examples/lib/stories/input/input.dart +++ b/examples/lib/stories/input/input.dart @@ -3,6 +3,7 @@ import 'package:examples/commons/commons.dart'; import 'package:examples/stories/input/advanced_button_example.dart'; import 'package:examples/stories/input/double_tap_callbacks_example.dart'; import 'package:examples/stories/input/drag_callbacks_example.dart'; +import 'package:examples/stories/input/dynamic_scale_drag_example.dart'; import 'package:examples/stories/input/gesture_hitboxes_example.dart'; import 'package:examples/stories/input/hardware_keyboard_example.dart'; import 'package:examples/stories/input/hover_callbacks_example.dart'; @@ -143,5 +144,11 @@ void addInputStories(Dashbook dashbook) { (_) => GameWidget(game: AdvancedButtonExample()), codeLink: baseLink('input/advanced_button_example.dart'), info: AdvancedButtonExample.description, + ) + ..add( + 'Dynamic Scale & Drag', + (_) => GameWidget(game: DynamicScaleDragExample()), + codeLink: baseLink('input/dynamic_scale_drag_example.dart'), + info: DynamicScaleDragExample.description, ); } From a77ceb0ec369992cac2a09337b583378521aa8a3 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 2 Mar 2026 23:40:41 +0100 Subject: [PATCH 48/87] docs: Document combining ScaleCallbacks and DragCallbacks Add Scale Events to the inputs TOC. Document the isDragged/isScaling properties, combining both mixins on a single component, and dynamic addition of components with different callback types at runtime. --- doc/flame/inputs/drag_events.md | 48 +++++++++++++++++++++++++++++++ doc/flame/inputs/inputs.md | 2 ++ doc/flame/inputs/scale_events.md | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/doc/flame/inputs/drag_events.md b/doc/flame/inputs/drag_events.md index d910ec24f34..d499bddea99 100644 --- a/doc/flame/inputs/drag_events.md +++ b/doc/flame/inputs/drag_events.md @@ -113,6 +113,54 @@ method must be implemented manually. If your component is a part of a larger hierarchy, then it will only receive drag events if its ancestors have all implemented the `containsLocalPoint` correctly. + +### isDragged + +The `DragCallbacks` mixin provides an `isDragged` getter that returns `true` while the component is +actively being dragged. This is set to `true` at `onDragStart` and back to `false` at `onDragEnd`. +It can be used, for example, to change the component's visual appearance during a drag. + + +## Combining with ScaleCallbacks + +A component can use both `DragCallbacks` and `ScaleCallbacks` at the same time. When both mixins are +present, single-finger gestures produce drag events and two-finger gestures produce both drag and +scale events. This is useful for components that should be draggable with one finger and +pinch-zoomable or rotatable with two fingers. + +```dart +class InteractiveRect extends RectangleComponent + with ScaleCallbacks, DragCallbacks { + + @override + void onDragUpdate(DragUpdateEvent event) { + position += event.localDelta; + } + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + // store initial angle/scale for relative updates + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + angle = initialAngle + event.rotation; + } +} +``` + + +### Dynamic addition + +Components with different callback types can be added to the game at any time. For example, you can +start with only `DragCallbacks` components and later add a `ScaleCallbacks` component. Flame will +automatically reconfigure the gesture handling so that both types work correctly. Any gestures that +are already in progress (e.g. an ongoing drag) will continue uninterrupted during this transition. + +See also [Scale Events — Combining with DragCallbacks](scale_events.md#combining-with-dragcallbacks). + + ```dart class MyComponent extends PositionComponent with DragCallbacks { MyComponent({super.size}); diff --git a/doc/flame/inputs/inputs.md b/doc/flame/inputs/inputs.md index 252ec9b8aea..b7b37cabd8d 100644 --- a/doc/flame/inputs/inputs.md +++ b/doc/flame/inputs/inputs.md @@ -10,6 +10,7 @@ works, but adapted for Flame's component tree. - [Tap Events](tap_events.md) - [Drag Events](drag_events.md) +- [Scale Events](scale_events.md) - [Gesture Input](gesture_input.md) - [Keyboard Input](keyboard_input.md) - [Other Inputs and Helpers](other_inputs.md) @@ -21,6 +22,7 @@ works, but adapted for Flame's component tree. Tap Events Drag Events +Scale Events Gesture Input Keyboard Input Other Inputs diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 981d8e164c8..af233f16e25 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -100,6 +100,55 @@ method must be implemented manually. If your component is a part of a larger hierarchy, then it will only receive scale events if its ancestors have all implemented the `containsLocalPoint` correctly. + +### isScaling + +The `ScaleCallbacks` mixin provides an `isScaling` getter that returns `true` while the component is +actively being scaled. This is set to `true` at the start of `onScaleStart` and back to `false` at +`onScaleEnd`. It can be used, for example, to change the component's visual appearance during a scale +gesture. + + +## Combining with DragCallbacks + +A component can use both `ScaleCallbacks` and `DragCallbacks` at the same time. When both mixins are +present, single-finger gestures produce drag events and two-finger gestures produce both scale and +drag events. This is useful for components that should be draggable with one finger and +pinch-zoomable or rotatable with two fingers. + +```dart +class InteractiveRect extends RectangleComponent + with ScaleCallbacks, DragCallbacks { + + @override + void onDragUpdate(DragUpdateEvent event) { + position += event.localDelta; + } + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + // store initial angle/scale for relative updates + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + angle = initialAngle + event.rotation; + } +} +``` + + +### Dynamic addition + +Components with different callback types can be added to the game at any time. For example, you can +start with only `DragCallbacks` components and later add a `ScaleCallbacks` component. Flame will +automatically reconfigure the gesture handling so that both types work correctly. Any gestures that +are already in progress (e.g. an ongoing drag) will continue uninterrupted during this transition. + +See also [Drag Events — Combining with ScaleCallbacks](drag_events.md#combining-with-scalecallbacks). + + ```dart class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { ScaleOnlyRectangle({ From 9e23b3e11ebdd7dc17d43911e2b25890b59275e6 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 2 Mar 2026 23:51:45 +0100 Subject: [PATCH 49/87] fix: Put control body on separate line in ScaleDispatcher --- doc/flame/inputs/drag_events.md | 2 +- doc/flame/inputs/scale_events.md | 2 +- .../lib/src/events/flame_game_mixins/scale_dispatcher.dart | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/doc/flame/inputs/drag_events.md b/doc/flame/inputs/drag_events.md index d499bddea99..4997b55486f 100644 --- a/doc/flame/inputs/drag_events.md +++ b/doc/flame/inputs/drag_events.md @@ -126,7 +126,7 @@ It can be used, for example, to change the component's visual appearance during A component can use both `DragCallbacks` and `ScaleCallbacks` at the same time. When both mixins are present, single-finger gestures produce drag events and two-finger gestures produce both drag and scale events. This is useful for components that should be draggable with one finger and -pinch-zoomable or rotatable with two fingers. +pinch-to-zoom or rotatable with two fingers. ```dart class InteractiveRect extends RectangleComponent diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index af233f16e25..a3592126676 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -114,7 +114,7 @@ gesture. A component can use both `ScaleCallbacks` and `DragCallbacks` at the same time. When both mixins are present, single-finger gestures produce drag events and two-finger gestures produce both scale and drag events. This is useful for components that should be draggable with one finger and -pinch-zoomable or rotatable with two fingers. +pinch-to-zoom or rotatable with two fingers. ```dart class InteractiveRect extends RectangleComponent 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 66732e8d85e..eb73528a4fa 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 @@ -88,7 +88,9 @@ class ScaleDispatcher extends Component implements ScaleListener { @internal @override void handleScaleStart(ScaleStartDetails details) { - if (_shouldBeRemoved) return; + if (_shouldBeRemoved) { + return; + } onScaleStart(ScaleStartEvent(0, game, details)); } From 82b4c3fece9f5bbbaea195a877be476e97054aa7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 00:32:18 +0100 Subject: [PATCH 50/87] refactor: Remove unnecessary hide imports and run dart format --- .../input/dynamic_scale_drag_example.dart | 54 +++++++++---------- .../lib/stories/input/scale_drag_example.dart | 6 +-- .../events/multi_drag_scale_recognizer.dart | 3 +- .../component_mixins/input_test_helper.dart | 3 +- .../scale_drag_callbacks_test.dart | 4 +- 5 files changed, 34 insertions(+), 36 deletions(-) diff --git a/examples/lib/stories/input/dynamic_scale_drag_example.dart b/examples/lib/stories/input/dynamic_scale_drag_example.dart index f9c661cbac5..d935d61d29c 100644 --- a/examples/lib/stories/input/dynamic_scale_drag_example.dart +++ b/examples/lib/stories/input/dynamic_scale_drag_example.dart @@ -1,9 +1,9 @@ import 'dart:math'; import 'package:flame/components.dart'; -import 'package:flame/events.dart' hide PointerMoveEvent; +import 'package:flame/events.dart'; import 'package:flame/game.dart'; -import 'package:flutter/material.dart' hide PointerMoveEvent; +import 'package:flutter/material.dart'; class DynamicScaleDragExample extends FlameGame { static const String description = ''' @@ -145,10 +145,10 @@ class _Button extends PositionComponent with TapCallbacks { required super.position, required Color color, required VoidCallback onPressed, - }) : _color = color, - _onPressed = onPressed, - _text = text, - super(size: Vector2(100, 30)); + }) : _color = color, + _onPressed = onPressed, + _text = text, + super(size: Vector2(100, 30)); final Color _color; final VoidCallback _onPressed; @@ -189,13 +189,13 @@ class _DragBox extends RectangleComponent required String label, required Vector2 position, required Color color, - }) : _label = label, - super( - position: position, - size: Vector2.all(120), - anchor: Anchor.center, - paint: Paint()..color = color, - ); + }) : _label = label, + super( + position: position, + size: Vector2.all(120), + anchor: Anchor.center, + paint: Paint()..color = color, + ); final String _label; @@ -225,13 +225,13 @@ class _ScaleBox extends RectangleComponent with ScaleCallbacks { required String label, required Vector2 position, required Color color, - }) : _label = label, - super( - position: position, - size: Vector2.all(120), - anchor: Anchor.center, - paint: Paint()..color = color, - ); + }) : _label = label, + super( + position: position, + size: Vector2.all(120), + anchor: Anchor.center, + paint: Paint()..color = color, + ); final String _label; double _initialAngle = 0; @@ -277,13 +277,13 @@ class _DragScaleBox extends RectangleComponent required String label, required Vector2 position, required Color color, - }) : _label = label, - super( - position: position, - size: Vector2.all(120), - anchor: Anchor.center, - paint: Paint()..color = color, - ); + }) : _label = label, + super( + position: position, + size: Vector2.all(120), + anchor: Anchor.center, + paint: Paint()..color = color, + ); final String _label; double _initialAngle = 0; diff --git a/examples/lib/stories/input/scale_drag_example.dart b/examples/lib/stories/input/scale_drag_example.dart index 886061808e3..3ead6f0b753 100644 --- a/examples/lib/stories/input/scale_drag_example.dart +++ b/examples/lib/stories/input/scale_drag_example.dart @@ -1,8 +1,8 @@ import 'dart:math'; -import 'package:flame/components.dart' hide Matrix4; +import 'package:flame/components.dart'; import 'package:flame/events.dart' hide PointerMoveEvent; -import 'package:flame/game.dart' hide Matrix4; -import 'package:flutter/material.dart' hide PointerMoveEvent, Matrix4; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart' hide PointerMoveEvent; void main() { runApp(GameWidget(game: ScaleExample())); diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index a3916b3d745..bae6d8cf4d0 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -305,8 +305,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { var totalHorizontalDeviation = 0.0; var totalVerticalDeviation = 0.0; for (final state in _pointers.values) { - totalDeviation += - (_currentFocalPoint! - state.currentPosition).distance; + totalDeviation += (_currentFocalPoint! - state.currentPosition).distance; totalHorizontalDeviation += (_currentFocalPoint!.dx - state.currentPosition.dx).abs(); totalVerticalDeviation += 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 9b20af1ba1e..e5d3db28345 100644 --- a/packages/flame/test/events/component_mixins/input_test_helper.dart +++ b/packages/flame/test/events/component_mixins/input_test_helper.dart @@ -318,6 +318,7 @@ extension ZoomTesting on WidgetTester { await handlePointerEventRecord(records); }); } + Future zoomFrom({ required Offset startLocation1, required Offset offset1, @@ -405,8 +406,6 @@ extension ZoomTesting on WidgetTester { } } - - class ScaleCallbacksComponent extends PositionComponent with ScaleCallbacks, ScaleCounter {} 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 c1a80bde417..a98721cc438 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 @@ -1,5 +1,5 @@ import 'package:flame/components.dart'; -import 'package:flame/events.dart' hide PointerMoveEvent; +import 'package:flame/events.dart'; import 'package:flame/game.dart'; import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flame_test/flame_test.dart'; @@ -668,4 +668,4 @@ void main() { }, ); }); -} \ No newline at end of file +} From 390a8cffec2b27d5e4af7d57b5862ad81f1de9cc Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 10:15:54 +0100 Subject: [PATCH 51/87] Update packages/flame/lib/src/events/multi_drag_scale_recognizer.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/flame/lib/src/events/multi_drag_scale_recognizer.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index bae6d8cf4d0..8da88788b0c 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -147,7 +147,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _initialVerticalSpan = _currentVerticalSpan; _initialLine = _currentLine; _initialScaleEventTimestamp = event.timeStamp; - _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); + _scaleVelocityTracker = VelocityTracker.withKind(event.kind); if (onScaleStart != null) { invokeCallback('onScaleStart', () { From 03990fb5b86244b288ce5202e704ca24d429e019 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 10:56:55 +0100 Subject: [PATCH 52/87] fix: Remove unused dragStartBehavior from MultiDragScaleGestureRecognizer The property was declared but never used in the recognizer logic. ImmediateMultiDragGestureRecognizer, which this recognizer is modeled after, does not have this property either. --- .../flame/lib/src/events/multi_drag_scale_recognizer.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 8da88788b0c..00de1817c52 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -16,7 +16,6 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { super.debugOwner, super.supportedDevices, AllowedButtonsFilter? allowedButtonsFilter, - this.dragStartBehavior = DragStartBehavior.down, this.scaleThreshold = 1.05, }) : super( allowedButtonsFilter: @@ -27,9 +26,6 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { 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; From 3be0f7c26e0b371bee939d731ee5118c00c99be8 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 10:59:14 +0100 Subject: [PATCH 53/87] fix: Remove unused material.dart import from MultiDragDispatcher All needed types are already provided by flutter/gestures.dart. --- .../lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart | 1 - 1 file changed, 1 deletion(-) 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 1cab782f305..afae52aa8a6 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 @@ -5,7 +5,6 @@ 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 { From 15a30aaf49fc1e6f3289ba3a4f335e7a3652dcd4 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 11:01:23 +0100 Subject: [PATCH 54/87] fix: Restore dt-based camera updates in scale_drag_example The rotation and zoom increments were accidentally changed to fixed per-frame deltas, making them frame-rate dependent. Restore the original 0.1 * dt scaling. --- examples/lib/stories/input/scale_drag_example.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/lib/stories/input/scale_drag_example.dart b/examples/lib/stories/input/scale_drag_example.dart index 3ead6f0b753..6f59886a3ad 100644 --- a/examples/lib/stories/input/scale_drag_example.dart +++ b/examples/lib/stories/input/scale_drag_example.dart @@ -67,11 +67,11 @@ class ScaleExample extends FlameGame { super.update(dt); if (addCameraRotation) { - camera.viewfinder.angle += 0.001; + camera.viewfinder.angle += 0.1 * dt; } if (addZoom) { debugText.text = '${camera.viewfinder.zoom}'; - camera.viewfinder.zoom += 0.001; + camera.viewfinder.zoom += 0.1 * dt; } } } From 590442127c741469984569c4eecdf78fbb8df228 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 11:36:09 +0100 Subject: [PATCH 55/87] fix: Gate MultiDragDispatcher onStart callback when marked for removal Prevent creating orphaned FlameDragAdapter instances when the dispatcher is marked for removal during a dispatcher upgrade. Returns null from the recognizer's onStart callback so the pointer is cleanly rejected rather than accepted and silently dropped. --- .../events/flame_game_mixins/multi_drag_dispatcher.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 afae52aa8a6..6b0dd157bbb 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 @@ -166,7 +166,12 @@ class MultiDragDispatcher extends Component implements MultiDragListener { game.gestureDetectors.add( ImmediateMultiDragGestureRecognizer.new, (ImmediateMultiDragGestureRecognizer instance) { - instance.onStart = (Offset point) => FlameDragAdapter(this, point); + instance.onStart = (Offset point) { + if (_shouldBeRemoved) { + return null; + } + return FlameDragAdapter(this, point); + }; }, ); } From 2edf728dbb67c0254a2555c8b772a4eb05768544 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 11:51:51 +0100 Subject: [PATCH 56/87] docs: Document markForRemoval gesture overlap behavior Document on both MultiDragDispatcher and ScaleDispatcher that new gestures are silently dropped during the overlap window while active gestures from the old dispatcher are still completing. --- .../flame_game_mixins/multi_drag_dispatcher.dart | 13 +++++++++++++ .../events/flame_game_mixins/scale_dispatcher.dart | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) 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 6b0dd157bbb..8985a638d49 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 @@ -140,6 +140,19 @@ class MultiDragDispatcher extends Component implements MultiDragListener { _tryRemoving(); } + /// Marks this dispatcher for removal after all active gestures end. + /// + /// This is called during a dispatcher upgrade (e.g. when a + /// [ScaleCallbacks] component is added and the system upgrades from + /// [MultiDragDispatcher] to [MultiDragScaleDispatcher]). + /// + /// Active gestures continue to be delivered until the user lifts their + /// finger, at which point the dispatcher removes itself. During this + /// overlap window, new gestures are silently dropped: the old + /// [ImmediateMultiDragGestureRecognizer] still wins the gesture arena + /// (it accepts immediately), but the `onStart` callback returns null + /// so no [FlameDragAdapter] is created. The new dispatcher's recognizer + /// is rejected by the arena for those pointers. void markForRemoval() { _shouldBeRemoved = true; _tryRemoving(); 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 eb73528a4fa..5350c0efb10 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 @@ -109,14 +109,24 @@ class ScaleDispatcher extends Component implements ScaleListener { //#endregion + /// Marks this dispatcher for removal after all active gestures end. + /// + /// This is called during a dispatcher upgrade (e.g. when a + /// [DragCallbacks] component is added and the system upgrades from + /// [ScaleDispatcher] to [MultiDragScaleDispatcher]). + /// + /// Active gestures continue to be delivered until they complete + /// naturally, at which point the dispatcher removes itself. During this + /// overlap window, new gestures are rejected by the + /// [handleScaleStart] guard. void markForRemoval() { _shouldBeRemoved = true; _tryRemoving(); } bool _tryRemoving() { - // there's no more fingers - // that started dragging before _shouldBeRemoved flag was set to true. + // There are no more active gestures that started before + // _shouldBeRemoved flag was set to true. if (_records.isEmpty && _shouldBeRemoved && isMounted) { removeFromParent(); return true; From d980fb37930161a37f91e9ce374cda857cf0f033 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 13:56:54 +0100 Subject: [PATCH 57/87] fix: Use scale focal point for drag updates during pinch gestures During 2+ pointer scale gestures, drag updates now follow the focal point (center between fingers) instead of an individual pointer. Also fixes inverted drag in the example when a component is rotated. --- .../input/dynamic_scale_drag_example.dart | 10 +++++++++- .../src/events/multi_drag_scale_recognizer.dart | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/examples/lib/stories/input/dynamic_scale_drag_example.dart b/examples/lib/stories/input/dynamic_scale_drag_example.dart index d935d61d29c..00d0a935633 100644 --- a/examples/lib/stories/input/dynamic_scale_drag_example.dart +++ b/examples/lib/stories/input/dynamic_scale_drag_example.dart @@ -305,7 +305,15 @@ class _DragScaleBox extends RectangleComponent @override void onDragUpdate(DragUpdateEvent event) { - position += event.localDelta; + // Transform localDelta from the component's rotated/scaled local space + // back to parent space so drag works correctly when rotated. + final d = event.localDelta; + final c = cos(angle); + final s = sin(angle); + position += Vector2( + c * scale.x * d.x - s * scale.y * d.y, + s * scale.x * d.x + c * scale.y * d.y, + ); } @override diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 00de1817c52..149283e15cc 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -183,6 +183,21 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { ); }); } + + // Send focal-point-based drag update during scale gesture + for (final state in _pointers.values) { + if (state._drag != null) { + state._drag!.update( + DragUpdateDetails( + globalPosition: _currentFocalPoint!, + delta: _delta, + sourceTimeStamp: event.timeStamp, + localPosition: _localFocalPoint, + ), + ); + break; // Only send through one adapter + } + } } } @@ -462,7 +477,7 @@ class _DragPointerState { } } - if (_drag != null) { + if (_drag != null && recognizer._pointers.length < 2) { _drag!.update( DragUpdateDetails( globalPosition: event.position, From 7968c39daadc1b3b70650ca9e063f0417f645ef8 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 14:31:27 +0100 Subject: [PATCH 58/87] fix: Compute focalPointDelta in global coordinates _delta was computed from _localFocalPoint (after event.transform) but used as ScaleUpdateDetails.focalPointDelta and DragUpdateDetails.delta, both of which pair with global positions. Compute from global _currentFocalPoint instead to keep coordinate spaces consistent. --- packages/flame/lib/src/events/multi_drag_scale_recognizer.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 149283e15cc..4dc6a2117cb 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -303,12 +303,11 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { ); _delta = Offset.zero; } else { - final localPreviousFocalPoint = _localFocalPoint; _localFocalPoint = PointerEvent.transformPosition( _lastTransform, _currentFocalPoint!, ); - _delta = _localFocalPoint - localPreviousFocalPoint; + _delta = _currentFocalPoint! - previousFocalPoint; } // Calculate span using the already-computed focal point From 18276f057bbd0de0bcae7614babe8a31df309fd1 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 14:33:54 +0100 Subject: [PATCH 59/87] chore: Rewrite timedZoomFrom test helper to remove CC BY-SA code Replace StackOverflow-sourced ZoomTesting.timedZoomFrom with an original implementation to resolve license incompatibility with the MIT-licensed repo. --- .../component_mixins/input_test_helper.dart | 121 ++++++++---------- 1 file changed, 52 insertions(+), 69 deletions(-) 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 e5d3db28345..39b1d39aa70 100644 --- a/packages/flame/test/events/component_mixins/input_test_helper.dart +++ b/packages/flame/test/events/component_mixins/input_test_helper.dart @@ -232,15 +232,13 @@ class ScaleDragWithCallbacksComponent extends PositionComponent } } -// Source - https://stackoverflow.com/a/75171528 -// Posted by Alexander -// Retrieved 2025-11-19, License - CC BY-SA 4.0 - extension ZoomTesting on WidgetTester { + /// Simulates a timed two-finger pinch/zoom gesture by generating pointer + /// event records with accurate timestamps for both pointers. Future timedZoomFrom( - Offset startLocation1, + Offset start1, Offset offset1, - Offset startLocation2, + Offset start2, Offset offset2, Duration duration, { int? pointer, @@ -248,72 +246,57 @@ extension ZoomTesting on WidgetTester { 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 p1 = pointer ?? nextPointer; + final p2 = p1 + 1; + 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, - ), + // Both pointers land simultaneously at t=0. + PointerEventRecord(Duration.zero, [ + PointerAddedEvent(position: start1), + PointerAddedEvent(position: start2), + PointerDownEvent(position: start1, pointer: p1, buttons: buttons), + PointerDownEvent(position: start2, pointer: p2, buttons: buttons), ]), ]; + + // Generate interleaved move events for both pointers at each step. + var prev1 = start1; + var prev2 = start2; + for (var step = 0; step <= intervals; step++) { + final t = step / intervals; + final ts = duration * step ~/ intervals; + final pos1 = start1 + offset1 * t; + final pos2 = start2 + offset2 * t; + records.add( + PointerEventRecord(ts, [ + PointerMoveEvent( + timeStamp: ts, + position: pos1, + delta: pos1 - prev1, + pointer: p1, + buttons: buttons, + ), + PointerMoveEvent( + timeStamp: ts, + position: pos2, + delta: pos2 - prev2, + pointer: p2, + buttons: buttons, + ), + ]), + ); + prev1 = pos1; + prev2 = pos2; + } + + // Both pointers lift at the end. + records.add( + PointerEventRecord(duration, [ + PointerUpEvent(timeStamp: duration, position: prev1, pointer: p1), + PointerUpEvent(timeStamp: duration, position: prev2, pointer: p2), + ]), + ); + return TestAsyncUtils.guard(() async { await handlePointerEventRecord(records); }); From 31fa8ffa425629f4f3446c48f0d19784fc0c1392 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 3 Mar 2026 14:35:11 +0100 Subject: [PATCH 60/87] refactor: Remove abbreviations in timedZoomFrom test helper --- .../component_mixins/input_test_helper.dart | 62 ++++++++++++------- 1 file changed, 39 insertions(+), 23 deletions(-) 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 39b1d39aa70..395dd463247 100644 --- a/packages/flame/test/events/component_mixins/input_test_helper.dart +++ b/packages/flame/test/events/component_mixins/input_test_helper.dart @@ -246,54 +246,70 @@ extension ZoomTesting on WidgetTester { int intervals = 30, }) { assert(intervals > 1); - final p1 = pointer ?? nextPointer; - final p2 = p1 + 1; + final pointer1 = pointer ?? nextPointer; + final pointer2 = pointer1 + 1; final records = [ // Both pointers land simultaneously at t=0. PointerEventRecord(Duration.zero, [ PointerAddedEvent(position: start1), PointerAddedEvent(position: start2), - PointerDownEvent(position: start1, pointer: p1, buttons: buttons), - PointerDownEvent(position: start2, pointer: p2, buttons: buttons), + PointerDownEvent( + position: start1, + pointer: pointer1, + buttons: buttons, + ), + PointerDownEvent( + position: start2, + pointer: pointer2, + buttons: buttons, + ), ]), ]; // Generate interleaved move events for both pointers at each step. - var prev1 = start1; - var prev2 = start2; + var previousPosition1 = start1; + var previousPosition2 = start2; for (var step = 0; step <= intervals; step++) { - final t = step / intervals; - final ts = duration * step ~/ intervals; - final pos1 = start1 + offset1 * t; - final pos2 = start2 + offset2 * t; + final progress = step / intervals; + final timeStamp = duration * step ~/ intervals; + final position1 = start1 + offset1 * progress; + final position2 = start2 + offset2 * progress; records.add( - PointerEventRecord(ts, [ + PointerEventRecord(timeStamp, [ PointerMoveEvent( - timeStamp: ts, - position: pos1, - delta: pos1 - prev1, - pointer: p1, + timeStamp: timeStamp, + position: position1, + delta: position1 - previousPosition1, + pointer: pointer1, buttons: buttons, ), PointerMoveEvent( - timeStamp: ts, - position: pos2, - delta: pos2 - prev2, - pointer: p2, + timeStamp: timeStamp, + position: position2, + delta: position2 - previousPosition2, + pointer: pointer2, buttons: buttons, ), ]), ); - prev1 = pos1; - prev2 = pos2; + previousPosition1 = position1; + previousPosition2 = position2; } // Both pointers lift at the end. records.add( PointerEventRecord(duration, [ - PointerUpEvent(timeStamp: duration, position: prev1, pointer: p1), - PointerUpEvent(timeStamp: duration, position: prev2, pointer: p2), + PointerUpEvent( + timeStamp: duration, + position: previousPosition1, + pointer: pointer1, + ), + PointerUpEvent( + timeStamp: duration, + position: previousPosition2, + pointer: pointer2, + ), ]), ); From acc0d12a276afe6e60fa1defb02cd0cbcbdbb878 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 14:57:13 +0200 Subject: [PATCH 61/87] fix: Remove markForRemoval in favor of immediate removal with cancel events Replace the delayed-removal mechanism (markForRemoval/_shouldBeRemoved/ _tryRemoving) with direct removeFromParent() calls. When a dispatcher is removed during an upgrade, it now immediately cancels/ends any active gestures via onDragCancel/onScaleEnd before tearing down, giving components a proper notification instead of silently dropping events. Also remove unnecessary hide PointerMoveEvent from scale_drag_example imports and fix a _DragCallbacksComponent typo in tests. --- .../lib/stories/input/scale_drag_example.dart | 4 +- .../component_mixins/drag_callbacks.dart | 4 +- .../component_mixins/scale_callbacks.dart | 2 +- .../multi_drag_dispatcher.dart | 51 +++---------------- .../flame_game_mixins/scale_dispatcher.dart | 41 +++------------ .../component_mixins/drag_callbacks_test.dart | 32 +++--------- .../scale_callbacks_test.dart | 31 +++-------- 7 files changed, 30 insertions(+), 135 deletions(-) diff --git a/examples/lib/stories/input/scale_drag_example.dart b/examples/lib/stories/input/scale_drag_example.dart index 6f59886a3ad..54f2a8c8057 100644 --- a/examples/lib/stories/input/scale_drag_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' hide PointerMoveEvent; +import 'package:flame/events.dart'; import 'package:flame/game.dart'; -import 'package:flutter/material.dart' hide PointerMoveEvent; +import 'package:flutter/material.dart'; void main() { runApp(GameWidget(game: ScaleExample())); 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 98c1ba58c69..4f5a3b76e5c 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -83,7 +83,7 @@ mixin DragCallbacks on Component { final dispatcher = MultiDragScaleDispatcher(); game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); - (multiDragDispatcher as MultiDragDispatcher).markForRemoval(); + multiDragDispatcher.removeFromParent(); return; } @@ -108,7 +108,7 @@ mixin DragCallbacks on Component { final dispatcher = MultiDragScaleDispatcher(); game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); - (scaleDispatcher as ScaleDispatcher).markForRemoval(); + scaleDispatcher.removeFromParent(); } } } 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 f7456865182..2198c50bb97 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -51,7 +51,7 @@ mixin ScaleCallbacks on Component { final dispatcher = MultiDragScaleDispatcher(); game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); - (multiDragDispatcher as MultiDragDispatcher).markForRemoval(); + multiDragDispatcher.removeFromParent(); } } } 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 90ab9f750c5..87d70221305 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 @@ -30,8 +30,6 @@ class MultiDragDispatcher extends Dispatcher /// The record of all components currently being touched. final Set> _records = {}; - bool _shouldBeRemoved = false; - final _dragUpdateController = StreamController.broadcast( sync: true, ); @@ -157,9 +155,6 @@ class MultiDragDispatcher extends Dispatcher @internal @override void handleDragStart(int pointerId, DragStartDetails details) { - if (_shouldBeRemoved) { - return; - } final event = DragStartEvent(pointerId, game, details); onDragStart(event); _dragStartController.add(event); @@ -179,7 +174,6 @@ class MultiDragDispatcher extends Dispatcher final event = DragEndEvent(pointerId, details); onDragEnd(event); _dragEndController.add(event); - _tryRemoving(); } @internal @@ -188,35 +182,6 @@ class MultiDragDispatcher extends Dispatcher final event = DragCancelEvent(pointerId); onDragCancel(event); _dragCancelController.add(event); - _tryRemoving(); - } - - /// Marks this dispatcher for removal after all active gestures end. - /// - /// This is called during a dispatcher upgrade (e.g. when a - /// [ScaleCallbacks] component is added and the system upgrades from - /// [MultiDragDispatcher] to [MultiDragScaleDispatcher]). - /// - /// Active gestures continue to be delivered until the user lifts their - /// finger, at which point the dispatcher removes itself. During this - /// overlap window, new gestures are silently dropped: the old - /// [ImmediateMultiDragGestureRecognizer] still wins the gesture arena - /// (it accepts immediately), but the `onStart` callback returns null - /// so no [FlameDragAdapter] is created. The new dispatcher's recognizer - /// is rejected by the arena for those pointers. - 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 @@ -231,25 +196,21 @@ class MultiDragDispatcher extends Dispatcher @override void onMount() { - if (_tryRemoving()) { - return; - } - game.gestureDetectors.register( ImmediateMultiDragGestureRecognizer.new, (ImmediateMultiDragGestureRecognizer instance) { - instance.onStart = (Offset point) { - if (_shouldBeRemoved) { - return null; - } - return FlameDragAdapter(this, point); - }; + instance.onStart = (Offset point) => FlameDragAdapter(this, point); }, ); } @override void onRemove() { + final activeRecords = _records.toList(); + _records.clear(); + for (final record in activeRecords) { + record.component.onDragCancel(DragCancelEvent(record.pointerId)); + } game.gestureDetectors.unregister(); Dispatcher.removeDispatcher(game, const MultiDragDispatcherKey()); _dragUpdateController.close(); 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 f9c14136c04..6961fa29b3c 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 @@ -27,8 +27,6 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { /// Records all components currently being scaled, keyed by pointerId. final Set> _records = {}; - bool _shouldBeRemoved = false; - /// Called when the user starts a scale gesture. @mustCallSuper void onScaleStart(ScaleStartEvent event) { @@ -87,9 +85,6 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { @internal @override void handleScaleStart(ScaleStartDetails details) { - if (_shouldBeRemoved) { - return; - } onScaleStart(ScaleStartEvent(0, game, details)); } @@ -103,36 +98,10 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { @override void handleScaleEnd(ScaleEndDetails details) { onScaleEnd(ScaleEndEvent(0, details)); - _tryRemoving(); } //#endregion - /// Marks this dispatcher for removal after all active gestures end. - /// - /// This is called during a dispatcher upgrade (e.g. when a - /// [DragCallbacks] component is added and the system upgrades from - /// [ScaleDispatcher] to [MultiDragScaleDispatcher]). - /// - /// Active gestures continue to be delivered until they complete - /// naturally, at which point the dispatcher removes itself. During this - /// overlap window, new gestures are rejected by the - /// [handleScaleStart] guard. - void markForRemoval() { - _shouldBeRemoved = true; - _tryRemoving(); - } - - bool _tryRemoving() { - // There are no more active gestures that started before - // _shouldBeRemoved flag was set to true. - if (_records.isEmpty && _shouldBeRemoved && isMounted) { - removeFromParent(); - return true; - } - return false; - } - static void addDispatcher(Component component) { Dispatcher.addDispatcher( component, @@ -143,9 +112,6 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { @override void onMount() { - if (_tryRemoving()) { - return; - } game.gestureDetectors.register( ScaleGestureRecognizer.new, (ScaleGestureRecognizer instance) { @@ -160,6 +126,13 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { @override void onRemove() { + final activeRecords = _records.toList(); + _records.clear(); + for (final record in activeRecords) { + record.component.onScaleEnd( + ScaleEndEvent(record.pointerId, ScaleEndDetails()), + ); + } game.gestureDetectors.unregister(); Dispatcher.removeDispatcher(game, const ScaleDispatcherKey()); super.onRemove(); 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 edff6672c51..23f3deb95e2 100644 --- a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart @@ -76,7 +76,7 @@ void main() { testWithFlameGame( 'removed dragged component receives cancel on update and clears state', (game) async { - final component = _DragCallbacksComponent() + final component = DragCallbacksComponent() ..x = 10 ..y = 10 ..width = 10 @@ -438,27 +438,7 @@ void main() { group('MultiDragDispatcher lifecycle', () { testWithFlameGame( - 'rejects new gestures after markForRemoval', - (game) async { - final component = DragCallbacksComponent() - ..x = 10 - ..y = 10 - ..width = 10 - ..height = 10; - await game.ensureAdd(component); - final dispatcher = game.firstChild()!; - - dispatcher.markForRemoval(); - dispatcher.handleDragStart( - 1, - DragStartDetails(globalPosition: const Offset(12, 12)), - ); - expect(component.dragStartEvent, 0); - }, - ); - - testWithFlameGame( - 'removes itself after last gesture ends when marked', + 'cancels active gestures and removes itself immediately on removal', (game) async { final component = DragCallbacksComponent() ..x = 10 @@ -473,12 +453,12 @@ void main() { DragStartDetails(globalPosition: const Offset(12, 12)), ); expect(component.dragStartEvent, 1); + expect(component.isDragged, isTrue); - dispatcher.markForRemoval(); - expect(dispatcher.isMounted, isTrue); - - dispatcher.handleDragEnd(1, DragEndDetails()); + dispatcher.removeFromParent(); game.update(0); + expect(component.isDragged, isFalse); + expect(component.dragCancelEvent, 1); expect(game.children.whereType().length, 0); }, ); 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 c500ff8b23e..ed6f98b8dab 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -372,26 +372,7 @@ void main() { group('ScaleDispatcher lifecycle', () { testWithFlameGame( - 'rejects new gestures after markForRemoval', - (game) async { - final component = ScaleCallbacksComponent() - ..x = 10 - ..y = 10 - ..width = 10 - ..height = 10; - await game.ensureAdd(component); - final dispatcher = game.firstChild()!; - - dispatcher.markForRemoval(); - dispatcher.handleScaleStart( - ScaleStartDetails(focalPoint: const Offset(12, 12)), - ); - expect(component.scaleStartEvent, 0); - }, - ); - - testWithFlameGame( - 'removes itself after last gesture ends when marked', + 'ends active gestures and removes itself immediately on removal', (game) async { final component = ScaleCallbacksComponent() ..x = 10 @@ -405,12 +386,12 @@ void main() { ScaleStartDetails(focalPoint: const Offset(12, 12)), ); expect(component.scaleStartEvent, 1); + expect(component.isScaling, isTrue); - dispatcher.markForRemoval(); - expect(dispatcher.isMounted, isTrue); - - dispatcher.handleScaleEnd(ScaleEndDetails()); + dispatcher.removeFromParent(); game.update(0); + expect(component.isScaling, isFalse); + expect(component.scaleEndEvent, 1); expect(game.children.whereType().length, 0); }, ); @@ -422,7 +403,7 @@ void main() { expect(game.children.whereType().length, 1); final dispatcher = game.firstChild()!; - dispatcher.markForRemoval(); + dispatcher.removeFromParent(); game.update(0); expect(game.children.whereType().length, 0); From 6a16df80b866c3fd640aae635853207f3cdecb86 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 15:01:58 +0200 Subject: [PATCH 62/87] fix: Restore markForRemoval - keep active gestures, reject new ones The markForRemoval approach is correct: active gestures complete naturally while new ones are rejected, allowing a clean handover to the upgraded dispatcher without disrupting ongoing input. --- .../component_mixins/drag_callbacks.dart | 4 +- .../component_mixins/scale_callbacks.dart | 2 +- .../multi_drag_dispatcher.dart | 44 ++++++++++++++++--- .../flame_game_mixins/scale_dispatcher.dart | 38 +++++++++++++--- .../component_mixins/drag_callbacks_test.dart | 30 ++++++++++--- .../scale_callbacks_test.dart | 31 ++++++++++--- 6 files changed, 122 insertions(+), 27 deletions(-) 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 4f5a3b76e5c..98c1ba58c69 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -83,7 +83,7 @@ mixin DragCallbacks on Component { final dispatcher = MultiDragScaleDispatcher(); game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); - multiDragDispatcher.removeFromParent(); + (multiDragDispatcher as MultiDragDispatcher).markForRemoval(); return; } @@ -108,7 +108,7 @@ mixin DragCallbacks on Component { final dispatcher = MultiDragScaleDispatcher(); game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); - scaleDispatcher.removeFromParent(); + (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 2198c50bb97..f7456865182 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -51,7 +51,7 @@ mixin ScaleCallbacks on Component { final dispatcher = MultiDragScaleDispatcher(); game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); game.add(dispatcher); - multiDragDispatcher.removeFromParent(); + (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 87d70221305..a291518beca 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 @@ -30,6 +30,8 @@ class MultiDragDispatcher extends Dispatcher /// The record of all components currently being touched. final Set> _records = {}; + bool _shouldBeRemoved = false; + final _dragUpdateController = StreamController.broadcast( sync: true, ); @@ -155,6 +157,9 @@ class MultiDragDispatcher extends Dispatcher @internal @override void handleDragStart(int pointerId, DragStartDetails details) { + if (_shouldBeRemoved) { + return; + } final event = DragStartEvent(pointerId, game, details); onDragStart(event); _dragStartController.add(event); @@ -174,6 +179,7 @@ class MultiDragDispatcher extends Dispatcher final event = DragEndEvent(pointerId, details); onDragEnd(event); _dragEndController.add(event); + _tryRemoving(); } @internal @@ -182,6 +188,29 @@ class MultiDragDispatcher extends Dispatcher final event = DragCancelEvent(pointerId); onDragCancel(event); _dragCancelController.add(event); + _tryRemoving(); + } + + /// Marks this dispatcher for removal after all active gestures end. + /// + /// This is called during a dispatcher upgrade (e.g. when a + /// [ScaleCallbacks] component is added and the system upgrades from + /// [MultiDragDispatcher] to [MultiDragScaleDispatcher]). + /// + /// Active gestures continue to be delivered until the user lifts their + /// finger, at which point the dispatcher removes itself. New gestures + /// are rejected during this window so the new dispatcher can take over. + void markForRemoval() { + _shouldBeRemoved = true; + _tryRemoving(); + } + + bool _tryRemoving() { + if (_records.isEmpty && _shouldBeRemoved && isMounted) { + removeFromParent(); + return true; + } + return false; } //#endregion @@ -196,21 +225,24 @@ class MultiDragDispatcher extends Dispatcher @override void onMount() { + if (_tryRemoving()) { + return; + } game.gestureDetectors.register( ImmediateMultiDragGestureRecognizer.new, (ImmediateMultiDragGestureRecognizer instance) { - instance.onStart = (Offset point) => FlameDragAdapter(this, point); + instance.onStart = (Offset point) { + if (_shouldBeRemoved) { + return null; + } + return FlameDragAdapter(this, point); + }; }, ); } @override void onRemove() { - final activeRecords = _records.toList(); - _records.clear(); - for (final record in activeRecords) { - record.component.onDragCancel(DragCancelEvent(record.pointerId)); - } game.gestureDetectors.unregister(); Dispatcher.removeDispatcher(game, const MultiDragDispatcherKey()); _dragUpdateController.close(); 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 6961fa29b3c..b2c8a243ade 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 @@ -27,6 +27,8 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { /// Records all components currently being scaled, keyed by pointerId. final Set> _records = {}; + bool _shouldBeRemoved = false; + /// Called when the user starts a scale gesture. @mustCallSuper void onScaleStart(ScaleStartEvent event) { @@ -85,6 +87,9 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { @internal @override void handleScaleStart(ScaleStartDetails details) { + if (_shouldBeRemoved) { + return; + } onScaleStart(ScaleStartEvent(0, game, details)); } @@ -98,10 +103,33 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { @override void handleScaleEnd(ScaleEndDetails details) { onScaleEnd(ScaleEndEvent(0, details)); + _tryRemoving(); } //#endregion + /// Marks this dispatcher for removal after all active gestures end. + /// + /// This is called during a dispatcher upgrade (e.g. when a + /// [DragCallbacks] component is added and the system upgrades from + /// [ScaleDispatcher] to [MultiDragScaleDispatcher]). + /// + /// Active gestures continue to be delivered until they complete + /// naturally, at which point the dispatcher removes itself. New gestures + /// are rejected during this window so the new dispatcher can take over. + void markForRemoval() { + _shouldBeRemoved = true; + _tryRemoving(); + } + + bool _tryRemoving() { + if (_records.isEmpty && _shouldBeRemoved && isMounted) { + removeFromParent(); + return true; + } + return false; + } + static void addDispatcher(Component component) { Dispatcher.addDispatcher( component, @@ -112,6 +140,9 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { @override void onMount() { + if (_tryRemoving()) { + return; + } game.gestureDetectors.register( ScaleGestureRecognizer.new, (ScaleGestureRecognizer instance) { @@ -126,13 +157,6 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { @override void onRemove() { - final activeRecords = _records.toList(); - _records.clear(); - for (final record in activeRecords) { - record.component.onScaleEnd( - ScaleEndEvent(record.pointerId, ScaleEndDetails()), - ); - } game.gestureDetectors.unregister(); Dispatcher.removeDispatcher(game, const ScaleDispatcherKey()); super.onRemove(); 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 23f3deb95e2..cdd69a92af9 100644 --- a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart @@ -438,7 +438,27 @@ void main() { group('MultiDragDispatcher lifecycle', () { testWithFlameGame( - 'cancels active gestures and removes itself immediately on removal', + 'rejects new gestures after markForRemoval', + (game) async { + final component = DragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.markForRemoval(); + dispatcher.handleDragStart( + 1, + DragStartDetails(globalPosition: const Offset(12, 12)), + ); + expect(component.dragStartEvent, 0); + }, + ); + + testWithFlameGame( + 'removes itself after last gesture ends when marked', (game) async { final component = DragCallbacksComponent() ..x = 10 @@ -453,12 +473,12 @@ void main() { DragStartDetails(globalPosition: const Offset(12, 12)), ); expect(component.dragStartEvent, 1); - expect(component.isDragged, isTrue); - dispatcher.removeFromParent(); + dispatcher.markForRemoval(); + expect(dispatcher.isMounted, isTrue); + + dispatcher.handleDragEnd(1, DragEndDetails()); game.update(0); - expect(component.isDragged, isFalse); - expect(component.dragCancelEvent, 1); expect(game.children.whereType().length, 0); }, ); 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 ed6f98b8dab..c500ff8b23e 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -372,7 +372,26 @@ void main() { group('ScaleDispatcher lifecycle', () { testWithFlameGame( - 'ends active gestures and removes itself immediately on removal', + 'rejects new gestures after markForRemoval', + (game) async { + final component = ScaleCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.markForRemoval(); + dispatcher.handleScaleStart( + ScaleStartDetails(focalPoint: const Offset(12, 12)), + ); + expect(component.scaleStartEvent, 0); + }, + ); + + testWithFlameGame( + 'removes itself after last gesture ends when marked', (game) async { final component = ScaleCallbacksComponent() ..x = 10 @@ -386,12 +405,12 @@ void main() { ScaleStartDetails(focalPoint: const Offset(12, 12)), ); expect(component.scaleStartEvent, 1); - expect(component.isScaling, isTrue); - dispatcher.removeFromParent(); + dispatcher.markForRemoval(); + expect(dispatcher.isMounted, isTrue); + + dispatcher.handleScaleEnd(ScaleEndDetails()); game.update(0); - expect(component.isScaling, isFalse); - expect(component.scaleEndEvent, 1); expect(game.children.whereType().length, 0); }, ); @@ -403,7 +422,7 @@ void main() { expect(game.children.whereType().length, 1); final dispatcher = game.firstChild()!; - dispatcher.removeFromParent(); + dispatcher.markForRemoval(); game.update(0); expect(game.children.whereType().length, 0); From 5f9cacb735b25ff1e1814608113816315bb875bf Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 15:04:35 +0200 Subject: [PATCH 63/87] fix: Re-initialize span baseline when second pointer is added _initialSpan was set to 0 when only the first pointer was down, causing _checkScaleGestureThreshold to see a huge spanDelta on the first move event with 2 fingers and immediately accept the gesture regardless of scaleThreshold. Re-initialize the span fields when the second pointer arrives so the threshold measures actual finger movement from the true initial 2-finger position. --- .../flame/lib/src/events/multi_drag_scale_recognizer.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 4dc6a2117cb..394a3111f0f 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -97,6 +97,14 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _initialSpan = _currentSpan; _initialHorizontalSpan = _currentHorizontalSpan; _initialVerticalSpan = _currentVerticalSpan; + } else if (_pointers.length == 2) { + // Re-initialize span with actual 2-finger distance so the threshold + // check measures movement from this baseline, not from the zero span + // of the single-pointer state. + _update(); + _initialSpan = _currentSpan; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; } } From cbfaf9e5f93fbce7a4b53644f8858b91902b7f68 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 15:24:54 +0200 Subject: [PATCH 64/87] test: Add rotation test for MultiDragScaleGestureRecognizer Mirrors the rotation test from scale_callbacks_test.dart but targets the MultiDragScaleDispatcher path (ScaleDrag component with both mixins), verifying that cumulative rotation is correctly computed by the custom recognizer using the same atan2-based formula as Flutter's recognizer. --- .../scale_drag_callbacks_test.dart | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) 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 a98721cc438..8ff0cb59acd 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 @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/game.dart'; @@ -365,6 +367,57 @@ void main() { }, ); + testWidgets( + 'scale event rotation respects camera & zoom', + (tester) async { + final resolution = Vector2(80, 60); + final game = FlameGame( + camera: CameraComponent.withFixedResolution( + width: resolution.x, + height: resolution.y, + ), + ); + var rotations = []; + + game.camera.viewfinder.zoom = 3; + + await game.world.add( + ScaleDragWithCallbacksComponent( + position: Vector2.all(-5), + size: Vector2.all(10), + onScaleUpdate: (event) { + rotations.add(event.rotation); + }, + ), + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final canvasSize = game.canvasSize; + + final center = (canvasSize / 2).toOffset(); + await tester.timedZoomFrom( + center.translate(-1, 0), + const Offset(0, 20), + center.translate(1, 0), + const Offset(0, -20), + const Duration(milliseconds: 300), + intervals: 10, + ); + + // computation of angle using trigonometry with triangle having a size + // of length 1 and one of length i. + final expected = List.generate(21, (i) => -atan(i)); + + // remove the first element that is registered twice in the simulation + rotations = rotations.sublist(1); + for (var i = 0; i < expected.length; i++) { + expect(rotations[i], closeTo(expected[i], 1e-6)); + } + }, + ); + testWidgets( 'drag event delta respects camera & zoom', (tester) async { From 0d7017cbcb72675c1b0d6e78a5f23256d069fea8 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 15:29:38 +0200 Subject: [PATCH 65/87] fix: Handle rejectGesture scale teardown and stale drag components rejectGesture now calls _endScaleIfNeeded (extracted from _updateScaleAfterRemoval) so that onScaleEnd fires and _scaleGestureActive is cleared when a pointer is rejected mid-gesture, preventing orphaned scale state. MultiDragScaleDispatcher.onDragUpdate now mirrors MultiDragDispatcher: it detects removed/removing components, sends them onDragCancel, and removes them from _records rather than delivering stale updates. --- .../scale_drag_dispatcher.dart | 28 ++++++- .../events/multi_drag_scale_recognizer.dart | 83 ++++++++++--------- .../scale_drag_callbacks_test.dart | 41 +++++++++ 3 files changed, 110 insertions(+), 42 deletions(-) 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 index 887f826811b..c40d1237367 100644 --- 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 @@ -61,21 +61,41 @@ class MultiDragScaleDispatcher extends Component @mustCallSuper void onDragUpdate(DragUpdateEvent event) { final updated = >{}; + final stale = >{}; event.deliverAtPoint( rootComponent: game, deliverToAll: true, eventHandler: (DragCallbacks component) { final record = TaggedComponent(event.pointerId, component); if (_records.contains(record)) { - component.onDragUpdate(event); - updated.add(record); + if (!component.isMounted || component.isRemoving) { + stale.add(record); + } else { + component.onDragUpdate(event); + updated.add(record); + } } }, ); for (final record in _records) { - if (record.pointerId == event.pointerId && !updated.contains(record)) { - record.component.onDragUpdate(event); + if (record.pointerId != event.pointerId) { + continue; + } + final component = record.component; + if (!component.isMounted || component.isRemoving) { + stale.add(record); + continue; + } + if (!updated.contains(record)) { + component.onDragUpdate(event); + } + } + if (stale.isNotEmpty) { + final cancelEvent = DragCancelEvent(event.pointerId); + for (final record in stale) { + record.component.onDragCancel(cancelEvent); } + _records.removeAll(stale); } } diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 394a3111f0f..141aca7f055 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -216,58 +216,62 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _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, - ), + _endScaleIfNeeded(); + } + + void _endScaleIfNeeded() { + if (!_scaleGestureActive || _pointers.length >= 2) { + return; + } + 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; } + + _scaleGestureActive = false; + _scaleVelocityTracker = null; } void _checkScaleGestureThreshold() { @@ -413,6 +417,9 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { if (state != null) { state._rejected(); _removeState(pointer); + _update(); + _updateLines(); + _endScaleIfNeeded(); } } 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 8ff0cb59acd..7756854febc 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 @@ -87,6 +87,47 @@ void main() { }, ); + testWithFlameGame( + 'removed dragged component receives cancel on update and clears state', + (game) async { + final component = ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onDragStart( + createDragStartEvents( + game: game, + localPosition: const Offset(12, 12), + globalPosition: const Offset(12, 12), + ), + ); + expect(component.isDragged, isTrue); + + game.remove(component); + await game.ready(); + + dispatcher.onDragUpdate( + createDragUpdateEvents( + game: game, + localPosition: const Offset(15, 15), + globalPosition: const Offset(15, 15), + ), + ); + + expect(component.dragCancelEvent, equals(1)); + expect(component.dragEndEvent, equals(1)); + expect(component.isDragged, isFalse); + + dispatcher.onDragEnd(DragEndEvent(1, DragEndDetails())); + expect(component.dragCancelEvent, equals(1)); + expect(component.dragEndEvent, equals(1)); + }, + ); + testWithFlameGame( 'scale and drag events update not called without onStart', (game) async { From 47d51f2ba7c0088ddd7d202364021d1af6c799c7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 15:58:56 +0200 Subject: [PATCH 66/87] fix: Each pointer fires its own drag update independently during scale 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. --- .../events/multi_drag_scale_recognizer.dart | 17 +-------- .../scale_drag_callbacks_test.dart | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 141aca7f055..ef09e28faba 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -191,21 +191,6 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { ); }); } - - // Send focal-point-based drag update during scale gesture - for (final state in _pointers.values) { - if (state._drag != null) { - state._drag!.update( - DragUpdateDetails( - globalPosition: _currentFocalPoint!, - delta: _delta, - sourceTimeStamp: event.timeStamp, - localPosition: _localFocalPoint, - ), - ); - break; // Only send through one adapter - } - } } } @@ -491,7 +476,7 @@ class _DragPointerState { } } - if (_drag != null && recognizer._pointers.length < 2) { + if (_drag != null) { _drag!.update( DragUpdateDetails( globalPosition: event.position, 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 7756854febc..6447488d3c0 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 @@ -365,6 +365,44 @@ void main() { }, ); + testWidgets( + 'two fingers on different components both receive drag updates', + (tester) async { + var nDragUpdatesA = 0; + var nDragUpdatesB = 0; + final game = FlameGame( + children: [ + ScaleDragWithCallbacksComponent( + position: Vector2(0, 0), + size: Vector2.all(100), + onDragUpdate: (e) => nDragUpdatesA++, + ), + ScaleDragWithCallbacksComponent( + position: Vector2(200, 0), + size: Vector2.all(100), + onDragUpdate: (e) => nDragUpdatesB++, + ), + ], + ); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + const intervals = 10; + await tester.timedZoomFrom( + const Offset(50, 50), + const Offset(10, 0), + const Offset(250, 50), + const Offset(-10, 0), + const Duration(milliseconds: 300), + intervals: intervals, + ); + + expect(nDragUpdatesA, greaterThan(0)); + expect(nDragUpdatesB, greaterThan(0)); + }, + ); + testWidgets( 'scale event scale factor respects camera & zoom', (tester) async { From fdf4b86247d8805a2c719706e471ac556bead151 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 16:01:23 +0200 Subject: [PATCH 67/87] fix: Guard _dispose against double-resolving the arena entry _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. --- .../flame/lib/src/events/multi_drag_scale_recognizer.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index ef09e28faba..8ce8e0787d5 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -519,7 +519,9 @@ class _DragPointerState { } void _dispose() { - _arenaEntry?.resolve(GestureDisposition.rejected); + if (!_resolved) { + _arenaEntry?.resolve(GestureDisposition.rejected); + } _arenaEntry = null; _drag = null; } From f2a4163d3bafe85b2ca822bfbd5aa8dfc8ad74e4 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 16:07:30 +0200 Subject: [PATCH 68/87] Use Vector2.all everywhere in the example --- examples/lib/stories/input/scale_drag_example.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/lib/stories/input/scale_drag_example.dart b/examples/lib/stories/input/scale_drag_example.dart index 54f2a8c8057..a3dfcf87e3c 100644 --- a/examples/lib/stories/input/scale_drag_example.dart +++ b/examples/lib/stories/input/scale_drag_example.dart @@ -31,19 +31,19 @@ class ScaleExample extends FlameGame { textRenderer: TextPaint( style: const TextStyle(fontSize: 25, color: Colors.white), ), - position: Vector2(50, 50), + position: Vector2.all(50), ); if (addScaleOnlyRectangle) { final scaleOnlyRectangle = ScaleOnlyRectangle( - position: Vector2(0, 0), + position: Vector2.zero(), size: Vector2.all(150), ); world.add(scaleOnlyRectangle); } if (addDragOnlyRectangle) { final dragOnlyRectangle = DragOnlyRectangle( - position: Vector2(-200, -200), + position: Vector2.all(-200), size: Vector2.all(150), color: Colors.green, ); @@ -52,7 +52,7 @@ class ScaleExample extends FlameGame { if (addScaleDragRectangle) { interactiveRectangle = InteractiveRectangle( - position: Vector2(100, 100), + position: Vector2.all(100), size: Vector2.all(150), color: Colors.red, ); From 2e2623c2e7093356113b33588ea355cd00f52950 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 16:21:14 +0200 Subject: [PATCH 69/87] fix: Handle stale scale components in onScaleUpdate dispatchers 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. --- .../flame_game_mixins/scale_dispatcher.dart | 27 ++++++- .../scale_drag_dispatcher.dart | 27 ++++++- .../scale_callbacks_test.dart | 39 ++++++++++ .../scale_drag_callbacks_test.dart | 72 +++++++++++++++++++ 4 files changed, 159 insertions(+), 6 deletions(-) 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 b2c8a243ade..1ff4bdcd3aa 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 @@ -45,6 +45,7 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { @mustCallSuper void onScaleUpdate(ScaleUpdateEvent event) { final updated = >{}; + final stale = >{}; // Deliver to components under the pointer event.deliverAtPoint( @@ -53,8 +54,12 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { eventHandler: (ScaleCallbacks component) { final record = TaggedComponent(event.pointerId, component); if (_records.contains(record)) { - component.onScaleUpdate(event); - updated.add(record); + if (!component.isMounted || component.isRemoving) { + stale.add(record); + } else { + component.onScaleUpdate(event); + updated.add(record); + } } }, ); @@ -64,10 +69,26 @@ class ScaleDispatcher extends Dispatcher implements ScaleListener { // Currently, the id passed to the scale // events is always 0, so maybe it's not relevant. for (final record in _records) { - if (record.pointerId == event.pointerId && !updated.contains(record)) { + if (record.pointerId != event.pointerId) { + continue; + } + final component = record.component; + if (!component.isMounted || component.isRemoving) { + stale.add(record); + continue; + } + if (!updated.contains(record)) { record.component.onScaleUpdate(event); } } + + if (stale.isNotEmpty) { + final endEvent = ScaleEndEvent(event.pointerId, ScaleEndDetails()); + for (final record in stale) { + record.component.onScaleEnd(endEvent); + } + _records.removeAll(stale); + } } /// Called when the scale gesture ends. 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 index c40d1237367..4b05ec3e5ae 100644 --- 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 @@ -174,6 +174,7 @@ class MultiDragScaleDispatcher extends Component @mustCallSuper void onScaleUpdate(ScaleUpdateEvent event) { final updated = >{}; + final stale = >{}; // Deliver to components under the pointer event.deliverAtPoint( @@ -182,8 +183,12 @@ class MultiDragScaleDispatcher extends Component eventHandler: (ScaleCallbacks component) { final record = TaggedComponent(event.pointerId, component); if (_scaleRecords.contains(record)) { - component.onScaleUpdate(event); - updated.add(record); + if (!component.isMounted || component.isRemoving) { + stale.add(record); + } else { + component.onScaleUpdate(event); + updated.add(record); + } } }, ); @@ -193,10 +198,26 @@ class MultiDragScaleDispatcher extends Component // 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)) { + if (record.pointerId != event.pointerId) { + continue; + } + final component = record.component; + if (!component.isMounted || component.isRemoving) { + stale.add(record); + continue; + } + if (!updated.contains(record)) { record.component.onScaleUpdate(event); } } + + if (stale.isNotEmpty) { + final endEvent = ScaleEndEvent(event.pointerId, ScaleEndDetails()); + for (final record in stale) { + record.component.onScaleEnd(endEvent); + } + _scaleRecords.removeAll(stale); + } } /// Called when the scale gesture ends. 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 c500ff8b23e..e76fa09c90b 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -75,6 +75,45 @@ void main() { expect(component.scaleEndEvent, equals(1)); }); + testWithFlameGame( + 'removed scale component receives scale end on update and clears state', + (game) async { + final component = ScaleCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.isScaling, isTrue); + + game.remove(component); + await game.ready(); + + dispatcher.onScaleUpdate( + createScaleUpdateEvents( + game: game, + localFocalPoint: const Offset(15, 15), + focalPoint: const Offset(15, 15), + ), + ); + + expect(component.scaleEndEvent, equals(1)); + expect(component.isScaling, isFalse); + + dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + expect(component.scaleEndEvent, equals(1)); + }, + ); + testWithFlameGame( 'scale event update not called without onScaleStart', (game) async { 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 6447488d3c0..1af61798ee5 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 @@ -128,6 +128,78 @@ void main() { }, ); + testWithFlameGame( + 'removed scale component receives scale end on update and clears state', + (game) async { + final component = ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onScaleStart( + createScaleStartEvents( + game: game, + localFocalPoint: const Offset(12, 12), + focalPoint: const Offset(12, 12), + ), + ); + expect(component.isScaling, isTrue); + + game.remove(component); + await game.ready(); + + dispatcher.onScaleUpdate( + createScaleUpdateEvents( + game: game, + localFocalPoint: const Offset(15, 15), + focalPoint: const Offset(15, 15), + ), + ); + + expect(component.scaleEndEvent, equals(1)); + expect(component.isScaling, isFalse); + + dispatcher.onScaleEnd(ScaleEndEvent(1, ScaleEndDetails())); + expect(component.scaleEndEvent, equals(1)); + }, + ); + + testWithFlameGame( + 'onDragCancel is routed to components that received onDragStart', + (game) async { + final component = ScaleDragCallbacksComponent() + ..x = 10 + ..y = 10 + ..width = 10 + ..height = 10; + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + + dispatcher.onDragStart( + createDragStartEvents( + game: game, + localPosition: const Offset(12, 12), + globalPosition: const Offset(12, 12), + ), + ); + expect(component.dragStartEvent, equals(1)); + expect(component.dragCancelEvent, equals(0)); + + dispatcher.onDragCancel(DragCancelEvent(1)); + expect(component.dragCancelEvent, equals(1)); + // onDragCancel delegates to onDragEnd internally + expect(component.dragEndEvent, equals(1)); + expect(component.isDragged, isFalse); + + // record removed after cancel; subsequent end for same pointer is a no-op + dispatcher.onDragEnd(DragEndEvent(1, DragEndDetails())); + expect(component.dragEndEvent, equals(1)); + }, + ); + testWithFlameGame( 'scale and drag events update not called without onStart', (game) async { From a73da2e190c961d1732f142e7d938563aac036d6 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 17:03:32 +0200 Subject: [PATCH 70/87] fix: Prevent double translation in DragScaleBox example 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. --- .../input/dynamic_scale_drag_example.dart | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/examples/lib/stories/input/dynamic_scale_drag_example.dart b/examples/lib/stories/input/dynamic_scale_drag_example.dart index 00d0a935633..1feb4ca57e0 100644 --- a/examples/lib/stories/input/dynamic_scale_drag_example.dart +++ b/examples/lib/stories/input/dynamic_scale_drag_example.dart @@ -305,15 +305,13 @@ class _DragScaleBox extends RectangleComponent @override void onDragUpdate(DragUpdateEvent event) { - // Transform localDelta from the component's rotated/scaled local space - // back to parent space so drag works correctly when rotated. - final d = event.localDelta; - final c = cos(angle); - final s = sin(angle); - position += Vector2( - c * scale.x * d.x - s * scale.y * d.y, - s * scale.x * d.x + c * scale.y * d.y, - ); + // When a scale gesture is active, translation is handled in onScaleUpdate + // via the focal point delta. Applying it here too would double-count the + // movement (one call per finger). + if (isScaling) { + return; + } + _applyLocalDeltaToPosition(event.localDelta); } @override @@ -325,6 +323,9 @@ class _DragScaleBox extends RectangleComponent @override void onScaleUpdate(ScaleUpdateEvent event) { + // Translate by focal point movement (single authoritative source). + _applyLocalDeltaToPosition(event.localDelta); + angle = _initialAngle + event.rotation; if (_lastScale != 0) { final delta = event.scale / _lastScale; @@ -333,4 +334,15 @@ class _DragScaleBox extends RectangleComponent } _lastScale = event.scale; } + + void _applyLocalDeltaToPosition(Vector2 d) { + // Transform from component local space back to parent space, accounting + // for this component's rotation and scale. + final c = cos(angle); + final s = sin(angle); + position += Vector2( + c * scale.x * d.x - s * scale.y * d.y, + s * scale.x * d.x + c * scale.y * d.y, + ); + } } From 3a9a5e4521ffc2a6f8a184e610895eae30aeaa18 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 17:18:35 +0200 Subject: [PATCH 71/87] refactor: Extract dispatcher selection logic to shared helper 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. --- .../component_mixins/dispatcher_setup.dart | 79 +++++++++++++++++++ .../component_mixins/drag_callbacks.dart | 51 ++---------- .../component_mixins/scale_callbacks.dart | 32 ++------ 3 files changed, 91 insertions(+), 71 deletions(-) create mode 100644 packages/flame/lib/src/events/component_mixins/dispatcher_setup.dart diff --git a/packages/flame/lib/src/events/component_mixins/dispatcher_setup.dart b/packages/flame/lib/src/events/component_mixins/dispatcher_setup.dart new file mode 100644 index 00000000000..fb901b158a3 --- /dev/null +++ b/packages/flame/lib/src/events/component_mixins/dispatcher_setup.dart @@ -0,0 +1,79 @@ +import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_drag_dispatcher.dart'; +import 'package:flame/src/game/flame_game.dart'; + +/// Ensures the correct event dispatcher is registered on [game] given which +/// callback types the mounting component needs. +/// +/// Pass [hasDrag] / [hasScale] based on the component's actual mixin set. +/// When a component has both mixins this must be called exactly once; the +/// DragCallbacks side calls it and ScaleCallbacks guards with an early return. +/// +/// Upgrade matrix: +/// +/// | existing | drag-only | scale-only | both | +/// |----------------|-----------|------------|-------| +/// | nothing | +MDrag | +Scale | +MDS | +/// | MultiDragDisp. | done | ->MDS | ->MDS | +/// | ScaleDisp. | ->MDS | done | ->MDS | +/// | MultiDragScale | done | done | done | +void setupEventDispatcher( + FlameGame game, { + required bool hasDrag, + required bool hasScale, +}) { + final existingScale = + game.findByKey(const ScaleDispatcherKey()) as ScaleDispatcher?; + final existingDrag = + game.findByKey(const MultiDragDispatcherKey()) as MultiDragDispatcher?; + final existingBoth = game.findByKey(const MultiDragScaleDispatcherKey()); + + // Already at the most capable dispatcher. + if (existingBoth != null) { + return; + } + + if (hasDrag && hasScale) { + final dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + existingDrag?.markForRemoval(); + existingScale?.markForRemoval(); + return; + } + + if (hasDrag) { + if (existingDrag != null) { + return; // MultiDragDispatcher already present. + } + if (existingScale != null) { + // A ScaleDispatcher is active: upgrade to handle drag too. + final dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + existingScale.markForRemoval(); + return; + } + final dispatcher = MultiDragDispatcher(); + game.registerKey(const MultiDragDispatcherKey(), dispatcher); + game.add(dispatcher); + return; + } + + // hasScale only (hasDrag is false here). + if (existingScale != null) { + return; // ScaleDispatcher already present. + } + if (existingDrag != null) { + // A MultiDragDispatcher is active: upgrade to handle scale too. + final dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + existingDrag.markForRemoval(); + return; + } + final dispatcher = ScaleDispatcher(); + game.registerKey(const ScaleDispatcherKey(), dispatcher); + game.add(dispatcher); +} 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 98c1ba58c69..a8a3134e375 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -1,6 +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:flame/src/events/component_mixins/dispatcher_setup.dart'; import 'package:meta/meta.dart'; /// This mixin can be added to a [Component] allowing it to receive drag events. @@ -64,51 +64,10 @@ mixin DragCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); - - final game = findRootGame()!; - final scaleDispatcher = game.findByKey(const ScaleDispatcherKey()); - final multiDragDispatcher = game.findByKey(const MultiDragDispatcherKey()); - final multiDragScaleDispatcher = game.findByKey( - const MultiDragScaleDispatcherKey(), + setupEventDispatcher( + findRootGame()!, + hasDrag: true, + hasScale: this is ScaleCallbacks, ); - - // 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 f7456865182..e401c7bb31a 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -1,9 +1,9 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; -import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; +import 'package:flame/src/events/component_mixins/dispatcher_setup.dart'; import 'package:flutter/foundation.dart'; -/// This callback uses [ScaleDispatcher] to route events. +/// Mixin for components that respond to scale (pinch/zoom/rotate) gestures. mixin ScaleCallbacks on Component { bool _isScaling = false; @@ -26,32 +26,14 @@ mixin ScaleCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); - // Skip if DragCallbacks will handle it + // DragCallbacks.onMount handles the full matrix for combined components. if (this is DragCallbacks) { return; } - - final game = findRootGame()!; - final scaleDispatcher = game.findByKey(const ScaleDispatcherKey()); - final multiDragDispatcher = game.findByKey(const MultiDragDispatcherKey()); - final multiDragScaleDispatcher = game.findByKey( - const MultiDragScaleDispatcherKey(), + setupEventDispatcher( + findRootGame()!, + hasDrag: false, + hasScale: true, ); - - // 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(); - } } } From 9ebb9a272ae547bc7027c3b5bc6fcf5338a39b4b Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 18:51:56 +0200 Subject: [PATCH 72/87] refactor: Unify to single MultiDragScaleGestureRecognizer for all event 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. --- packages/flame/lib/events.dart | 2 - .../component_mixins/dispatcher_setup.dart | 79 ---- .../component_mixins/drag_callbacks.dart | 7 +- .../component_mixins/scale_callbacks.dart | 8 +- .../multi_drag_dispatcher.dart | 256 ----------- .../flame_game_mixins/scale_dispatcher.dart | 185 -------- .../scale_drag_dispatcher.dart | 59 ++- .../events/multi_drag_scale_recognizer.dart | 418 ++++++++++-------- .../lib/src/events/tagged_component.dart | 3 +- .../components/joystick_component_test.dart | 6 +- .../component_mixins/drag_callbacks_test.dart | 68 +-- .../scale_callbacks_test.dart | 89 +--- .../scale_drag_callbacks_test.dart | 44 +- 13 files changed, 319 insertions(+), 905 deletions(-) delete mode 100644 packages/flame/lib/src/events/component_mixins/dispatcher_setup.dart delete mode 100644 packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart delete mode 100644 packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index d098d9c2970..3ede2c29060 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -17,8 +17,6 @@ export 'src/events/flame_game_mixins/double_tap_dispatcher.dart' show DoubleTapDispatcher, DoubleTapDispatcherKey; export 'src/events/flame_game_mixins/long_press_dispatcher.dart' show LongPressDispatcher, LongPressDispatcherKey; -export 'src/events/flame_game_mixins/multi_drag_dispatcher.dart' - show MultiDragDispatcher, MultiDragDispatcherKey; export 'src/events/flame_game_mixins/multi_tap_dispatcher.dart' show MultiTapDispatcher, MultiTapDispatcherKey; export 'src/events/flame_game_mixins/non_primary_tap_dispatcher.dart' diff --git a/packages/flame/lib/src/events/component_mixins/dispatcher_setup.dart b/packages/flame/lib/src/events/component_mixins/dispatcher_setup.dart deleted file mode 100644 index fb901b158a3..00000000000 --- a/packages/flame/lib/src/events/component_mixins/dispatcher_setup.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart'; -import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; -import 'package:flame/src/events/flame_game_mixins/scale_drag_dispatcher.dart'; -import 'package:flame/src/game/flame_game.dart'; - -/// Ensures the correct event dispatcher is registered on [game] given which -/// callback types the mounting component needs. -/// -/// Pass [hasDrag] / [hasScale] based on the component's actual mixin set. -/// When a component has both mixins this must be called exactly once; the -/// DragCallbacks side calls it and ScaleCallbacks guards with an early return. -/// -/// Upgrade matrix: -/// -/// | existing | drag-only | scale-only | both | -/// |----------------|-----------|------------|-------| -/// | nothing | +MDrag | +Scale | +MDS | -/// | MultiDragDisp. | done | ->MDS | ->MDS | -/// | ScaleDisp. | ->MDS | done | ->MDS | -/// | MultiDragScale | done | done | done | -void setupEventDispatcher( - FlameGame game, { - required bool hasDrag, - required bool hasScale, -}) { - final existingScale = - game.findByKey(const ScaleDispatcherKey()) as ScaleDispatcher?; - final existingDrag = - game.findByKey(const MultiDragDispatcherKey()) as MultiDragDispatcher?; - final existingBoth = game.findByKey(const MultiDragScaleDispatcherKey()); - - // Already at the most capable dispatcher. - if (existingBoth != null) { - return; - } - - if (hasDrag && hasScale) { - final dispatcher = MultiDragScaleDispatcher(); - game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); - game.add(dispatcher); - existingDrag?.markForRemoval(); - existingScale?.markForRemoval(); - return; - } - - if (hasDrag) { - if (existingDrag != null) { - return; // MultiDragDispatcher already present. - } - if (existingScale != null) { - // A ScaleDispatcher is active: upgrade to handle drag too. - final dispatcher = MultiDragScaleDispatcher(); - game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); - game.add(dispatcher); - existingScale.markForRemoval(); - return; - } - final dispatcher = MultiDragDispatcher(); - game.registerKey(const MultiDragDispatcherKey(), dispatcher); - game.add(dispatcher); - return; - } - - // hasScale only (hasDrag is false here). - if (existingScale != null) { - return; // ScaleDispatcher already present. - } - if (existingDrag != null) { - // A MultiDragDispatcher is active: upgrade to handle scale too. - final dispatcher = MultiDragScaleDispatcher(); - game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); - game.add(dispatcher); - existingDrag.markForRemoval(); - return; - } - final dispatcher = ScaleDispatcher(); - game.registerKey(const ScaleDispatcherKey(), dispatcher); - game.add(dispatcher); -} 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 a8a3134e375..1ff965131dd 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -1,6 +1,5 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; -import 'package:flame/src/events/component_mixins/dispatcher_setup.dart'; import 'package:meta/meta.dart'; /// This mixin can be added to a [Component] allowing it to receive drag events. @@ -12,7 +11,7 @@ import 'package:meta/meta.dart'; /// /// This mixin is the replacement of the Draggable mixin. /// -/// This callback uses [MultiDragDispatcher] to route events. +/// This callback uses [MultiDragScaleDispatcher] to route events. mixin DragCallbacks on Component { bool _isDragged = false; @@ -64,8 +63,8 @@ mixin DragCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); - setupEventDispatcher( - findRootGame()!, + MultiDragScaleDispatcher.addDispatcher( + this, hasDrag: true, hasScale: this is ScaleCallbacks, ); 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 e401c7bb31a..4428fe0d5c6 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -1,6 +1,5 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; -import 'package:flame/src/events/component_mixins/dispatcher_setup.dart'; import 'package:flutter/foundation.dart'; /// Mixin for components that respond to scale (pinch/zoom/rotate) gestures. @@ -26,12 +25,13 @@ mixin ScaleCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); - // DragCallbacks.onMount handles the full matrix for combined components. + // DragCallbacks.onMount handles the full registration for + // combined components. if (this is DragCallbacks) { return; } - setupEventDispatcher( - findRootGame()!, + MultiDragScaleDispatcher.addDispatcher( + this, hasDrag: false, hasScale: true, ); diff --git a/packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart deleted file mode 100644 index a291518beca..00000000000 --- a/packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart +++ /dev/null @@ -1,256 +0,0 @@ -import 'dart:async'; - -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/src/events/flame_drag_adapter.dart'; -import 'package:flame/src/events/flame_game_mixins/dispatcher.dart'; -import 'package:flame/src/events/tagged_component.dart'; -import 'package:flame/src/game/flame_game.dart'; -import 'package:flame/src/game/game_render_box.dart'; -import 'package:flutter/gestures.dart'; -import 'package:meta/meta.dart'; - -class MultiDragDispatcherKey implements ComponentKey { - const MultiDragDispatcherKey(); - - @override - int get hashCode => 91604879; // 'MultiDragDispatcherKey' as hashCode - - @override - bool operator ==(Object other) => - other is MultiDragDispatcherKey && other.hashCode == hashCode; -} - -/// **MultiDragDispatcher** facilitates dispatching of drag events to the -/// [DragCallbacks] components in the component tree. It will be attached to -/// the [FlameGame] instance automatically whenever any [DragCallbacks] -/// components are mounted into the component tree. -class MultiDragDispatcher extends Dispatcher - implements MultiDragListener { - /// The record of all components currently being touched. - final Set> _records = {}; - - bool _shouldBeRemoved = false; - - final _dragUpdateController = StreamController.broadcast( - sync: true, - ); - - Stream get onUpdate => _dragUpdateController.stream; - - final _dragStartController = StreamController.broadcast( - sync: true, - ); - - Stream get onStart => _dragStartController.stream; - - final _dragEndController = StreamController.broadcast( - sync: true, - ); - - Stream get onEnd => _dragEndController.stream; - - final _dragCancelController = StreamController.broadcast( - sync: true, - ); - - Stream get onCancel => _dragCancelController.stream; - - /// Called when the user initiates a drag gesture, for example by touching the - /// screen and then moving the finger. - /// - /// The handler propagates the [event] to any component located at the point - /// of touch and that uses the [DragCallbacks] mixin. The event will be first - /// delivered to the topmost such component, and then propagated to the - /// components below only if explicitly requested. - /// - /// Each [event] has an `event.pointerId` to keep track of multiple touches - /// that may occur simultaneously. - @mustCallSuper - void onDragStart(DragStartEvent event) { - event.deliverAtPoint( - rootComponent: game, - eventHandler: (DragCallbacks component) { - _records.add(TaggedComponent(event.pointerId, component)); - component.onDragStart(event); - }, - ); - } - - /// Called continuously during the drag as the user moves their finger. - /// - /// The default handler propagates this event to those components who received - /// the initial [onDragStart] event. If the position of the pointer is outside - /// of the bounds of the component, then this event will nevertheless be - /// delivered, however its `event.localPosition` property will contain NaNs. - @mustCallSuper - void onDragUpdate(DragUpdateEvent event) { - final updated = >{}; - // Defer cleanup so stale targets can be cancelled after iteration. - final stale = >{}; - event.deliverAtPoint( - rootComponent: game, - deliverToAll: true, - eventHandler: (DragCallbacks component) { - final record = TaggedComponent(event.pointerId, component); - if (_records.contains(record)) { - if (!component.isMounted || component.isRemoving) { - stale.add(record); - } else { - component.onDragUpdate(event); - updated.add(record); - } - } - }, - ); - for (final record in _records) { - if (record.pointerId != event.pointerId) { - continue; - } - final component = record.component; - if (!component.isMounted || component.isRemoving) { - stale.add(record); - continue; - } - if (!updated.contains(record)) { - component.onDragUpdate(event); - } - } - if (stale.isNotEmpty) { - final cancelEvent = DragCancelEvent(event.pointerId); - for (final record in stale) { - record.component.onDragCancel(cancelEvent); - } - _records.removeAll(stale); - } - } - - /// Called when the drag gesture finishes. - /// - /// The default handler will deliver this event to all components who has - /// previously received the corresponding [onDragStart] event and - /// [onDragUpdate]s. - @mustCallSuper - void onDragEnd(DragEndEvent event) { - _records.removeWhere((record) { - if (record.pointerId == event.pointerId) { - record.component.onDragEnd(event); - return true; - } - return false; - }); - } - - @mustCallSuper - void onDragCancel(DragCancelEvent event) { - _records.removeWhere((record) { - if (record.pointerId == event.pointerId) { - record.component.onDragCancel(event); - return true; - } - return false; - }); - } - - //#region MultiDragListener API - - @internal - @override - void handleDragStart(int pointerId, DragStartDetails details) { - if (_shouldBeRemoved) { - return; - } - final event = DragStartEvent(pointerId, game, details); - onDragStart(event); - _dragStartController.add(event); - } - - @internal - @override - void handleDragUpdate(int pointerId, DragUpdateDetails details) { - final event = DragUpdateEvent(pointerId, game, details); - onDragUpdate(event); - _dragUpdateController.add(event); - } - - @internal - @override - void handleDragEnd(int pointerId, DragEndDetails details) { - final event = DragEndEvent(pointerId, details); - onDragEnd(event); - _dragEndController.add(event); - _tryRemoving(); - } - - @internal - @override - void handleDragCancel(int pointerId) { - final event = DragCancelEvent(pointerId); - onDragCancel(event); - _dragCancelController.add(event); - _tryRemoving(); - } - - /// Marks this dispatcher for removal after all active gestures end. - /// - /// This is called during a dispatcher upgrade (e.g. when a - /// [ScaleCallbacks] component is added and the system upgrades from - /// [MultiDragDispatcher] to [MultiDragScaleDispatcher]). - /// - /// Active gestures continue to be delivered until the user lifts their - /// finger, at which point the dispatcher removes itself. New gestures - /// are rejected during this window so the new dispatcher can take over. - void markForRemoval() { - _shouldBeRemoved = true; - _tryRemoving(); - } - - bool _tryRemoving() { - if (_records.isEmpty && _shouldBeRemoved && isMounted) { - removeFromParent(); - return true; - } - return false; - } - - //#endregion - - static void addDispatcher(Component component) { - Dispatcher.addDispatcher( - component, - const MultiDragDispatcherKey(), - MultiDragDispatcher.new, - ); - } - - @override - void onMount() { - if (_tryRemoving()) { - return; - } - game.gestureDetectors.register( - ImmediateMultiDragGestureRecognizer.new, - (ImmediateMultiDragGestureRecognizer instance) { - instance.onStart = (Offset point) { - if (_shouldBeRemoved) { - return null; - } - return FlameDragAdapter(this, point); - }; - }, - ); - } - - @override - void onRemove() { - game.gestureDetectors.unregister(); - Dispatcher.removeDispatcher(game, const MultiDragDispatcherKey()); - _dragUpdateController.close(); - _dragCancelController.close(); - _dragStartController.close(); - _dragEndController.close(); - } - - @override - GameRenderBox get renderBox => game.renderBox; -} diff --git a/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart deleted file mode 100644 index 1ff4bdcd3aa..00000000000 --- a/packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/game.dart'; -import 'package:flame/src/events/flame_game_mixins/dispatcher.dart'; -import 'package:flame/src/events/interfaces/scale_listener.dart'; -import 'package:flame/src/events/tagged_component.dart'; -import 'package:flutter/gestures.dart'; -import 'package:meta/meta.dart'; - -/// Unique key for the [ScaleDispatcher] so the game can identify it. -class ScaleDispatcherKey implements ComponentKey { - const ScaleDispatcherKey(); - - @override - int get hashCode => 31650892; // arbitrary unique number - - @override - bool operator ==(Object other) => - other is ScaleDispatcherKey && other.hashCode == hashCode; -} - -/// A component that dispatches scale (pinch/zoom) events to components -/// implementing [ScaleCallbacks]. It will be attached to -/// the [FlameGame] instance automatically whenever any [ScaleCallbacks] -/// components are mounted into the component tree. -class ScaleDispatcher extends Dispatcher implements ScaleListener { - /// Records all components currently being scaled, keyed by pointerId. - final Set> _records = {}; - - bool _shouldBeRemoved = false; - - /// Called when the user starts a scale gesture. - @mustCallSuper - void onScaleStart(ScaleStartEvent event) { - event.deliverAtPoint( - rootComponent: game, - eventHandler: (ScaleCallbacks component) { - _records.add(TaggedComponent(event.pointerId, component)); - component.onScaleStart(event); - }, - ); - } - - /// Called continuously as the user updates the scale gesture. - @mustCallSuper - void onScaleUpdate(ScaleUpdateEvent event) { - final updated = >{}; - final stale = >{}; - - // Deliver to components under the pointer - event.deliverAtPoint( - rootComponent: game, - deliverToAll: true, - eventHandler: (ScaleCallbacks component) { - final record = TaggedComponent(event.pointerId, component); - if (_records.contains(record)) { - if (!component.isMounted || component.isRemoving) { - stale.add(record); - } else { - component.onScaleUpdate(event); - updated.add(record); - } - } - }, - ); - - // Also deliver to components that started the scale but weren't under - // the pointer this frame - // Currently, the id passed to the scale - // events is always 0, so maybe it's not relevant. - for (final record in _records) { - if (record.pointerId != event.pointerId) { - continue; - } - final component = record.component; - if (!component.isMounted || component.isRemoving) { - stale.add(record); - continue; - } - if (!updated.contains(record)) { - record.component.onScaleUpdate(event); - } - } - - if (stale.isNotEmpty) { - final endEvent = ScaleEndEvent(event.pointerId, ScaleEndDetails()); - for (final record in stale) { - record.component.onScaleEnd(endEvent); - } - _records.removeAll(stale); - } - } - - /// Called when the scale gesture ends. - @mustCallSuper - void onScaleEnd(ScaleEndEvent event) { - _records.removeWhere((record) { - if (record.pointerId == event.pointerId) { - record.component.onScaleEnd(event); - return true; - } - return false; - }); - } - - //#region ScaleListener API - - @internal - @override - void handleScaleStart(ScaleStartDetails details) { - if (_shouldBeRemoved) { - return; - } - 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)); - _tryRemoving(); - } - - //#endregion - - /// Marks this dispatcher for removal after all active gestures end. - /// - /// This is called during a dispatcher upgrade (e.g. when a - /// [DragCallbacks] component is added and the system upgrades from - /// [ScaleDispatcher] to [MultiDragScaleDispatcher]). - /// - /// Active gestures continue to be delivered until they complete - /// naturally, at which point the dispatcher removes itself. New gestures - /// are rejected during this window so the new dispatcher can take over. - void markForRemoval() { - _shouldBeRemoved = true; - _tryRemoving(); - } - - bool _tryRemoving() { - if (_records.isEmpty && _shouldBeRemoved && isMounted) { - removeFromParent(); - return true; - } - return false; - } - - static void addDispatcher(Component component) { - Dispatcher.addDispatcher( - component, - const ScaleDispatcherKey(), - ScaleDispatcher.new, - ); - } - - @override - void onMount() { - if (_tryRemoving()) { - return; - } - game.gestureDetectors.register( - ScaleGestureRecognizer.new, - (ScaleGestureRecognizer instance) { - instance - ..onStart = handleScaleStart - ..onUpdate = handleScaleUpdate - ..onEnd = handleScaleEnd; - }, - ); - super.onMount(); - } - - @override - void onRemove() { - game.gestureDetectors.unregister(); - Dispatcher.removeDispatcher(game, const ScaleDispatcherKey()); - super.onRemove(); - } -} 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 index 4b05ec3e5ae..f3ac50035b9 100644 --- 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 @@ -20,17 +20,64 @@ class MultiDragScaleDispatcherKey implements ComponentKey { other is MultiDragScaleDispatcherKey && other.hashCode == hashCode; } -/// **MultiDragScaleDispatcher** facilitates dispatching of both drag and scale -/// events to the [DragCallbacks] and [ScaleCallbacks] components in the -/// component tree. It will be attached to the [FlameGame] instance -/// automatically when both callback types are needed. +/// Dispatches both drag and scale events to [DragCallbacks] and +/// [ScaleCallbacks] components. Attached to the [FlameGame] automatically +/// when either callback type is first mounted. +/// +/// Use [enableDrag] and [enableScale] (called via [addDispatcher]) to control +/// which event types are forwarded to the underlying +/// [MultiDragScaleGestureRecognizer]. class MultiDragScaleDispatcher extends Component implements MultiDragListener, ScaleListener { /// The record of all components currently being touched. final Set> _records = {}; + bool _hasDrag = false; + bool _hasScale = false; + MultiDragScaleGestureRecognizer? _recognizer; + FlameGame get game => parent! as FlameGame; + /// Enables drag forwarding on the underlying recognizer. + /// + /// Safe to call before or after [onMount]. + void enableDrag() { + _hasDrag = true; + _recognizer?.hasDrag = true; + } + + /// Enables scale forwarding on the underlying recognizer. + /// + /// Safe to call before or after [onMount]. + void enableScale() { + _hasScale = true; + _recognizer?.hasScale = true; + } + + /// Ensures a [MultiDragScaleDispatcher] is registered on the game that owns + /// [component], then enables drag and/or scale as requested. + static void addDispatcher( + Component component, { + required bool hasDrag, + required bool hasScale, + }) { + final game = component.findRootGame()!; + var dispatcher = + game.findByKey(const MultiDragScaleDispatcherKey()) + as MultiDragScaleDispatcher?; + if (dispatcher == null) { + dispatcher = MultiDragScaleDispatcher(); + game.registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + game.add(dispatcher); + } + if (hasDrag) { + dispatcher.enableDrag(); + } + if (hasScale) { + dispatcher.enableScale(); + } + } + /// Called when the user initiates a drag gesture, for example by touching the /// screen and then moving the finger. /// @@ -259,6 +306,9 @@ class MultiDragScaleDispatcher extends Component game.gestureDetectors.register( MultiDragScaleGestureRecognizer.new, (MultiDragScaleGestureRecognizer instance) { + _recognizer = instance; + instance.hasDrag = _hasDrag; + instance.hasScale = _hasScale; instance.onStart = (Offset point) => FlameDragAdapter(this, point); instance.onScaleStart = handleScaleStart; instance.onScaleUpdate = handleScaleUpdate; @@ -269,6 +319,7 @@ class MultiDragScaleDispatcher extends Component @override void onRemove() { + _recognizer = null; game.gestureDetectors.unregister(); game.unregisterKey(const MultiDragScaleDispatcherKey()); } diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 8ce8e0787d5..3c65abb15de 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -4,12 +4,13 @@ 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. +/// This recognizer tracks each pointer independently (like Flutter's +/// ImmediateMultiDragGestureRecognizer) while also tracking the overall scale +/// gesture (like Flutter's ScaleGestureRecognizer). Each pointer can drag +/// independently, and when 2+ pointers are down, scale callbacks also fire. +/// +/// Use [hasDrag] and [hasScale] to enable only the features needed. Both +/// default to false; the dispatcher sets them via enableDrag/enableScale. class MultiDragScaleGestureRecognizer extends GestureRecognizer { /// Create a gesture recognizer for tracking multi-drag and scale gestures. MultiDragScaleGestureRecognizer({ @@ -30,6 +31,12 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { /// Default is 1.05 (5% change in scale). final double scaleThreshold; + /// Whether drag callbacks should fire. Controlled by the dispatcher. + bool hasDrag = false; + + /// Whether scale callbacks should fire. Controlled by the dispatcher. + bool hasScale = false; + /// Called when a pointer starts dragging. One callback per pointer. /// Return a Drag object to receive updates for this specific pointer. GestureMultiDragStartCallback? onStart; @@ -43,149 +50,126 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { /// 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; + final _DragState _drag = _DragState(); + final _ScaleState _scale = _ScaleState(); - double get _pointerVerticalScaleFactor => _initialVerticalSpan > 0.0 - ? _currentVerticalSpan / _initialVerticalSpan - : 1.0; + int get pointerCount => _drag.count; @override void addAllowedPointer(PointerDownEvent event) { - assert(!_pointers.containsKey(event.pointer)); - final state = _DragPointerState( - recognizer: this, - event: event, - ); - _pointers[event.pointer] = state; + 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, ); - // Initialize scale tracking when first pointer is added - if (_pointers.length == 1) { - _update(); - _initialFocalPoint = _currentFocalPoint; - _initialSpan = _currentSpan; - _initialHorizontalSpan = _currentHorizontalSpan; - _initialVerticalSpan = _currentVerticalSpan; - } else if (_pointers.length == 2) { - // Re-initialize span with actual 2-finger distance so the threshold - // check measures movement from this baseline, not from the zero span - // of the single-pointer state. - _update(); - _initialSpan = _currentSpan; - _initialHorizontalSpan = _currentHorizontalSpan; - _initialVerticalSpan = _currentVerticalSpan; + if (hasScale) { + if (_drag.count == 1) { + _updateScaleFields(); + _scale.initialFocalPoint = _scale.currentFocalPoint; + _scale.initialSpan = _scale.currentSpan; + _scale.initialHorizontalSpan = _scale.currentHorizontalSpan; + _scale.initialVerticalSpan = _scale.currentVerticalSpan; + } else if (_drag.count == 2) { + // Re-initialize span with actual 2-finger distance so the threshold + // check measures movement from this baseline, not from the zero span + // of the single-pointer state. + _updateScaleFields(); + _scale.initialSpan = _scale.currentSpan; + _scale.initialHorizontalSpan = _scale.currentHorizontalSpan; + _scale.initialVerticalSpan = _scale.currentVerticalSpan; + } } } void _handleEvent(PointerEvent event) { - assert(_pointers.containsKey(event.pointer)); - final state = _pointers[event.pointer]!; + assert(_drag.pointers.containsKey(event.pointer)); + final state = _drag.pointers[event.pointer]!; if (event is PointerMoveEvent) { state._move(event); - _updateScale(event); + if (hasScale) { + _updateScale(event); + } } else if (event is PointerUpEvent) { assert(event.delta == Offset.zero); state._up(event); _removeState(event.pointer); - _updateScaleAfterRemoval(event); + if (hasScale) { + _scale.lastTransform = event.transform; + _updateScaleFields(); + _updateLines(); + _endScaleIfNeeded(); + } } else if (event is PointerCancelEvent) { assert(event.delta == Offset.zero); state._cancel(event); _removeState(event.pointer); - _updateScaleAfterRemoval(event); + if (hasScale) { + _scale.lastTransform = event.transform; + _updateScaleFields(); + _updateLines(); + _endScaleIfNeeded(); + } } else if (event is! PointerDownEvent) { assert(false); } } - void _updateScale(PointerEvent event) { - _lastTransform = event.transform; - - // Update all pointer positions for scale calculation - _update(); + void _updateScale(PointerMoveEvent event) { + _scale.lastTransform = event.transform; + _updateScaleFields(); _updateLines(); - // Check if we should accept all gestures based on scale threshold - if (_pointers.length >= 2 && !_scaleGestureActive) { + if (_drag.count >= 2 && !_scale.active) { _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(event.kind); + if (!_scale.active && _drag.count >= 2) { + _scale.active = true; + _scale.initialFocalPoint = _scale.currentFocalPoint; + _scale.initialSpan = _scale.currentSpan; + _scale.initialHorizontalSpan = _scale.currentHorizontalSpan; + _scale.initialVerticalSpan = _scale.currentVerticalSpan; + _scale.initialLine = _scale.currentLine; + _scale.initialEventTimestamp = event.timeStamp; + _scale.velocityTracker = VelocityTracker.withKind(event.kind); if (onScaleStart != null) { invokeCallback('onScaleStart', () { onScaleStart!( ScaleStartDetails( - focalPoint: _currentFocalPoint!, - localFocalPoint: _localFocalPoint, + focalPoint: _scale.currentFocalPoint!, + localFocalPoint: _scale.localFocalPoint, pointerCount: pointerCount, - sourceTimeStamp: _initialScaleEventTimestamp, + sourceTimeStamp: _scale.initialEventTimestamp, ), ); }); } } - // Update scale gesture if active and we still have 2+ pointers - if (_scaleGestureActive && _pointers.length >= 2) { - _scaleVelocityTracker?.addPosition( + if (_scale.active && _drag.count >= 2) { + _scale.velocityTracker?.addPosition( event.timeStamp, - Offset(_pointerScaleFactor, 0), + Offset(_scale.scaleFactor, 0), ); if (onScaleUpdate != null) { invokeCallback('onScaleUpdate', () { onScaleUpdate!( ScaleUpdateDetails( - scale: _pointerScaleFactor, - horizontalScale: _pointerHorizontalScaleFactor, - verticalScale: _pointerVerticalScaleFactor, - focalPoint: _currentFocalPoint!, - localFocalPoint: _localFocalPoint, + scale: _scale.scaleFactor, + horizontalScale: _scale.horizontalScaleFactor, + verticalScale: _scale.verticalScaleFactor, + focalPoint: _scale.currentFocalPoint!, + localFocalPoint: _scale.localFocalPoint, rotation: _computeRotationFactor(), pointerCount: pointerCount, - focalPointDelta: _delta, + focalPointDelta: _scale.delta, sourceTimeStamp: event.timeStamp, ), ); @@ -194,22 +178,12 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } } - void _updateScaleAfterRemoval(PointerEvent event) { - _lastTransform = event.transform; - - // Update all pointer positions for scale calculation (after removal) - _update(); - _updateLines(); - - _endScaleIfNeeded(); - } - void _endScaleIfNeeded() { - if (!_scaleGestureActive || _pointers.length >= 2) { + if (!_scale.active || _drag.count >= 2) { return; } if (onScaleEnd != null) { - final velocity = _scaleVelocityTracker?.getVelocity() ?? Velocity.zero; + final velocity = _scale.velocityTracker?.getVelocity() ?? Velocity.zero; if (_isFlingGesture(velocity)) { final pixelsPerSecond = velocity.pixelsPerSecond; @@ -255,25 +229,21 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } } - _scaleGestureActive = false; - _scaleVelocityTracker = null; + _scale.reset(); } void _checkScaleGestureThreshold() { - if (_pointers.isEmpty || _initialFocalPoint == null) { + if (_drag.pointers.isEmpty || _scale.initialFocalPoint == null) { return; } - final spanDelta = (_currentSpan - _initialSpan).abs(); - final scaleFactor = _pointerScaleFactor; + final spanDelta = (_scale.currentSpan - _scale.initialSpan).abs(); + final scaleFactor = _scale.scaleFactor; + final kind = _drag.pointers.values.first.kind; - // 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) { + for (final state in _drag.pointers.values) { if (!state._resolved) { state._arenaEntry?.resolve(GestureDisposition.accepted); } @@ -281,91 +251,93 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } } - void _update() { - final previousFocalPoint = _currentFocalPoint; + void _updateScaleFields() { + final previousFocalPoint = _scale.currentFocalPoint; - // Compute the focal point var focalPoint = Offset.zero; - for (final state in _pointers.values) { + for (final state in _drag.pointers.values) { focalPoint += state.currentPosition; } - _currentFocalPoint = _pointers.isEmpty + _scale.currentFocalPoint = _drag.pointers.isEmpty ? Offset.zero - : focalPoint / _pointers.length.toDouble(); + : focalPoint / _drag.pointers.length.toDouble(); if (previousFocalPoint == null) { - _localFocalPoint = PointerEvent.transformPosition( - _lastTransform, - _currentFocalPoint!, + _scale.localFocalPoint = PointerEvent.transformPosition( + _scale.lastTransform, + _scale.currentFocalPoint!, ); - _delta = Offset.zero; + _scale.delta = Offset.zero; } else { - _localFocalPoint = PointerEvent.transformPosition( - _lastTransform, - _currentFocalPoint!, + _scale.localFocalPoint = PointerEvent.transformPosition( + _scale.lastTransform, + _scale.currentFocalPoint!, ); - _delta = _currentFocalPoint! - previousFocalPoint; + _scale.delta = _scale.currentFocalPoint! - previousFocalPoint; } - // Calculate span using the already-computed focal point var totalDeviation = 0.0; var totalHorizontalDeviation = 0.0; var totalVerticalDeviation = 0.0; - for (final state in _pointers.values) { - totalDeviation += (_currentFocalPoint! - state.currentPosition).distance; + for (final state in _drag.pointers.values) { + totalDeviation += + (_scale.currentFocalPoint! - state.currentPosition).distance; totalHorizontalDeviation += - (_currentFocalPoint!.dx - state.currentPosition.dx).abs(); + (_scale.currentFocalPoint!.dx - state.currentPosition.dx).abs(); totalVerticalDeviation += - (_currentFocalPoint!.dy - state.currentPosition.dy).abs(); + (_scale.currentFocalPoint!.dy - state.currentPosition.dy).abs(); } - final count = _pointers.length; - _currentSpan = count > 0 ? totalDeviation / count : 0.0; - _currentHorizontalSpan = count > 0 ? totalHorizontalDeviation / count : 0.0; - _currentVerticalSpan = count > 0 ? totalVerticalDeviation / count : 0.0; + final count = _drag.pointers.length; + _scale.currentSpan = count > 0 ? totalDeviation / count : 0.0; + _scale.currentHorizontalSpan = count > 0 + ? totalHorizontalDeviation / count + : 0.0; + _scale.currentVerticalSpan = count > 0 + ? totalVerticalDeviation / count + : 0.0; } void _updateLines() { - final count = _pointers.length; - final pointerIds = _pointers.keys.toList(); + final count = _drag.pointers.length; + final pointerIds = _drag.pointers.keys.toList(); if (count < 2) { - _initialLine = _currentLine; - } else if (_initialLine != null && - _initialLine!.pointerStartId == pointerIds[0] && - _initialLine!.pointerEndId == pointerIds[1]) { - _currentLine = _LineBetweenPointers( + _scale.initialLine = _scale.currentLine; + } else if (_scale.initialLine != null && + _scale.initialLine!.pointerStartId == pointerIds[0] && + _scale.initialLine!.pointerEndId == pointerIds[1]) { + _scale.currentLine = _LineBetweenPointers( pointerStartId: pointerIds[0], - pointerStartLocation: _pointers[pointerIds[0]]!.currentPosition, + pointerStartLocation: _drag.pointers[pointerIds[0]]!.currentPosition, pointerEndId: pointerIds[1], - pointerEndLocation: _pointers[pointerIds[1]]!.currentPosition, + pointerEndLocation: _drag.pointers[pointerIds[1]]!.currentPosition, ); } else { - _initialLine = _LineBetweenPointers( + _scale.initialLine = _LineBetweenPointers( pointerStartId: pointerIds[0], - pointerStartLocation: _pointers[pointerIds[0]]!.currentPosition, + pointerStartLocation: _drag.pointers[pointerIds[0]]!.currentPosition, pointerEndId: pointerIds[1], - pointerEndLocation: _pointers[pointerIds[1]]!.currentPosition, + pointerEndLocation: _drag.pointers[pointerIds[1]]!.currentPosition, ); - _currentLine = _initialLine; + _scale.currentLine = _scale.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; + if (_scale.initialLine != null && _scale.currentLine != null) { + final fx = _scale.initialLine!.pointerStartLocation.dx; + final fy = _scale.initialLine!.pointerStartLocation.dy; + final sx = _scale.initialLine!.pointerEndLocation.dx; + final sy = _scale.initialLine!.pointerEndLocation.dy; - final nfx = _currentLine!.pointerStartLocation.dx; - final nfy = _currentLine!.pointerStartLocation.dy; - final nsx = _currentLine!.pointerEndLocation.dx; - final nsy = _currentLine!.pointerEndLocation.dy; + final nfx = _scale.currentLine!.pointerStartLocation.dx; + final nfy = _scale.currentLine!.pointerStartLocation.dy; + final nsx = _scale.currentLine!.pointerEndLocation.dx; + final nsy = _scale.currentLine!.pointerEndLocation.dy; final angle1 = math.atan2(fy - sy, fx - sx); final angle2 = math.atan2(nfy - nsy, nfx - nsx); - factor = angle2 - angle1; } return factor; @@ -377,52 +349,51 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } Drag? _startDrag(Offset initialPosition, int pointer) { - assert(_pointers.containsKey(pointer)); - Drag? drag; - if (onStart != null) { - drag = invokeCallback('onStart', () { - return onStart!(initialPosition); - }); + assert(_drag.pointers.containsKey(pointer)); + if (!hasDrag || onStart == null) { + return null; } - return drag; + return invokeCallback('onStart', () => onStart!(initialPosition)); } @override void acceptGesture(int pointer) { - final state = _pointers[pointer]; + final state = _drag.pointers[pointer]; if (state == null) { - return; // Already removed + return; } state._accepted(() => _startDrag(state.initialPosition, pointer)); } @override void rejectGesture(int pointer) { - final state = _pointers[pointer]; + final state = _drag.pointers[pointer]; if (state != null) { state._rejected(); _removeState(pointer); - _update(); - _updateLines(); - _endScaleIfNeeded(); + if (hasScale) { + _updateScaleFields(); + _updateLines(); + _endScaleIfNeeded(); + } } } void _removeState(int pointer) { - if (!_pointers.containsKey(pointer)) { + if (!_drag.pointers.containsKey(pointer)) { return; } GestureBinding.instance.pointerRouter.removeRoute(pointer, _handleEvent); - _pointers.remove(pointer)!._dispose(); + _drag.pointers.remove(pointer)!._dispose(); } @override void dispose() { - final pointers = _pointers.keys.toList(); + final pointers = _drag.pointers.keys.toList(); for (final pointer in pointers) { _removeState(pointer); } - assert(_pointers.isEmpty); + assert(_drag.pointers.isEmpty); super.dispose(); } @@ -430,6 +401,48 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { String get debugDescription => 'multi-drag-scale'; } +/// Groups per-recognizer drag state. Per-pointer state lives in +/// [_DragPointerState]. +class _DragState { + final Map pointers = {}; + int get count => pointers.length; +} + +/// Groups all scale-tracking state for a [MultiDragScaleGestureRecognizer]. +class _ScaleState { + bool active = false; + Offset? initialFocalPoint; + Offset? currentFocalPoint; + double initialSpan = 0.0; + double currentSpan = 0.0; + double initialHorizontalSpan = 0.0; + double currentHorizontalSpan = 0.0; + double initialVerticalSpan = 0.0; + double currentVerticalSpan = 0.0; + Offset localFocalPoint = Offset.zero; + _LineBetweenPointers? initialLine; + _LineBetweenPointers? currentLine; + Matrix4? lastTransform; + Offset delta = Offset.zero; + VelocityTracker? velocityTracker; + Duration? initialEventTimestamp; + + double get scaleFactor => initialSpan > 0.0 ? currentSpan / initialSpan : 1.0; + + double get horizontalScaleFactor => initialHorizontalSpan > 0.0 + ? currentHorizontalSpan / initialHorizontalSpan + : 1.0; + + double get verticalScaleFactor => initialVerticalSpan > 0.0 + ? currentVerticalSpan / initialVerticalSpan + : 1.0; + + void reset() { + active = false; + velocityTracker = null; + } +} + class _DragPointerState { _DragPointerState({ required this.recognizer, @@ -450,6 +463,10 @@ class _DragPointerState { Drag? _drag; bool _resolved = false; + // Accumulates deltas before the gesture is accepted so they can be delivered + // as a single initial update (matches ImmediateMultiDragGestureRecognizer). + Offset _pendingDelta = Offset.zero; + set arenaEntry(GestureArenaEntry entry) { _arenaEntry = entry; } @@ -463,20 +480,21 @@ class _DragPointerState { 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)) { + _pendingDelta += delta; + if (!recognizer.hasScale) { + // Drag-only mode: accept on any movement. If accepted synchronously, + // _accepted fires the initial update with _pendingDelta; no extra + // update fires here because we are still in the if(!_resolved) branch. _arenaEntry?.resolve(GestureDisposition.accepted); + } else { + final distance = (currentPosition - initialPosition).distance; + if (distance > computePanSlop(kind, recognizer.gestureSettings)) { + _arenaEntry?.resolve(GestureDisposition.accepted); + } else if (recognizer._drag.count >= 2) { + recognizer._checkScaleGestureThreshold(); + } } - // 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) { + } else if (_drag != null) { _drag!.update( DragUpdateDetails( globalPosition: event.position, @@ -493,11 +511,7 @@ class _DragPointerState { void _up(PointerUpEvent event) { if (_drag != null) { - _drag!.end( - DragEndDetails( - velocity: velocityTracker.getVelocity(), - ), - ); + _drag!.end(DragEndDetails(velocity: velocityTracker.getVelocity())); } _resolved = true; } @@ -511,6 +525,20 @@ class _DragPointerState { 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, + ), + ); + _pendingDelta = Offset.zero; + } } } diff --git a/packages/flame/lib/src/events/tagged_component.dart b/packages/flame/lib/src/events/tagged_component.dart index b1b4dbb627c..0450dcfeb73 100644 --- a/packages/flame/lib/src/events/tagged_component.dart +++ b/packages/flame/lib/src/events/tagged_component.dart @@ -1,12 +1,11 @@ import 'package:flame/src/components/core/component.dart'; -import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart'; import 'package:flame/src/events/flame_game_mixins/multi_tap_dispatcher.dart'; import 'package:meta/meta.dart'; /// [TaggedComponent] is a utility class that represents a pair of a component /// and a pointer id. /// -/// This class is used by [MultiTapDispatcher] and [MultiDragDispatcher] +/// This class is used by [MultiTapDispatcher] and MultiDragScaleDispatcher /// to store information about which components were affected by which pointer /// event, so that subsequent events can be reliably delivered to the same /// components. diff --git a/packages/flame/test/components/joystick_component_test.dart b/packages/flame/test/components/joystick_component_test.dart index 0c7123bbd70..a3a2e878f18 100644 --- a/packages/flame/test/components/joystick_component_test.dart +++ b/packages/flame/test/components/joystick_component_test.dart @@ -1,5 +1,5 @@ import 'package:flame/components.dart'; -import 'package:flame/src/events/flame_game_mixins/multi_drag_dispatcher.dart'; +import 'package:flame/events.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/widgets.dart'; import 'package:test/test.dart'; @@ -85,7 +85,7 @@ void main() { await game.add(joystick); await game.ready(); expect(joystick.knob!.position, closeToVector(Vector2(10, 10))); - final dragDispatcher = game.firstChild()!; + final dragDispatcher = game.firstChild()!; // Start dragging the joystick dragDispatcher.handleDragStart( 1, @@ -131,7 +131,7 @@ void main() { ); await game.add(joystick); await game.ready(); - final dragDispatcher = game.firstChild()!; + final dragDispatcher = game.firstChild()!; dragDispatcher.handleDragStart( 1, diff --git a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart index cdd69a92af9..2e819e8a986 100644 --- a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart @@ -14,7 +14,7 @@ void main() { (game) async { await game.add(DragCallbacksComponent()); await game.ready(); - expect(game.children.toList()[2], isA()); + expect(game.children.toList()[2], isA()); }, ); @@ -27,8 +27,8 @@ void main() { game.add(component); await game.ready(); - expect(game.children.whereType().length, 1); - game.firstChild()!.onDragStart( + expect(game.children.whereType().length, 1); + game.firstChild()!.onDragStart( createDragStartEvents( game: game, localPosition: const Offset(12, 12), @@ -45,7 +45,7 @@ void main() { ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; dispatcher.onDragStart( createDragStartEvents( @@ -82,7 +82,7 @@ void main() { ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; dispatcher.onDragStart( createDragStartEvents( @@ -123,7 +123,7 @@ void main() { ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; expect(component.dragStartEvent, equals(0)); expect(component.dragUpdateEvent, equals(0)); @@ -185,7 +185,7 @@ void main() { (game) async { await game.ready(); expect(game.children.length, equals(3)); - expect(game.children.elementAt(1), isA()); + expect(game.children.elementAt(1), isA()); }, ); @@ -251,7 +251,7 @@ void main() { expect(game.children.length, 4); expect(game.children.elementAt(1), isA()); - expect(game.children.elementAt(2), isA()); + expect(game.children.elementAt(2), isA()); // regular drag await tester.timedDragFrom( @@ -292,7 +292,7 @@ void main() { await tester.pump(); await tester.pump(); expect(game.children.length, 5); - expect(game.children.elementAt(3), isA()); + expect(game.children.elementAt(3), isA()); await tester.timedDragFrom( const Offset(20, 20), @@ -320,7 +320,7 @@ void main() { await tester.pump(); await tester.pump(); expect(game.children.length, 4); - expect(game.children.elementAt(2), isA()); + expect(game.children.elementAt(2), isA()); await tester.timedDragFrom( const Offset(80, 80), @@ -435,52 +435,4 @@ void main() { expect(totalDelta, Vector2(16, 0)); }, ); - - group('MultiDragDispatcher lifecycle', () { - testWithFlameGame( - 'rejects new gestures after markForRemoval', - (game) async { - final component = DragCallbacksComponent() - ..x = 10 - ..y = 10 - ..width = 10 - ..height = 10; - await game.ensureAdd(component); - final dispatcher = game.firstChild()!; - - dispatcher.markForRemoval(); - dispatcher.handleDragStart( - 1, - DragStartDetails(globalPosition: const Offset(12, 12)), - ); - expect(component.dragStartEvent, 0); - }, - ); - - testWithFlameGame( - 'removes itself after last gesture ends when marked', - (game) async { - final component = DragCallbacksComponent() - ..x = 10 - ..y = 10 - ..width = 10 - ..height = 10; - await game.ensureAdd(component); - final dispatcher = game.firstChild()!; - - dispatcher.handleDragStart( - 1, - DragStartDetails(globalPosition: const Offset(12, 12)), - ); - expect(component.dragStartEvent, 1); - - dispatcher.markForRemoval(); - expect(dispatcher.isMounted, isTrue); - - dispatcher.handleDragEnd(1, DragEndDetails()); - game.update(0); - expect(game.children.whereType().length, 0); - }, - ); - }); } 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 e76fa09c90b..def3b419e44 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:flame/components.dart'; import 'package:flame/events.dart' hide PointerMoveEvent; import 'package:flame/game.dart'; -import 'package:flame/src/events/flame_game_mixins/scale_dispatcher.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -16,7 +15,7 @@ void main() { (game) async { await game.add(ScaleCallbacksComponent()); await game.ready(); - expect(game.children.toList()[2], isA()); + expect(game.children.toList()[2], isA()); }, ); }); @@ -29,8 +28,8 @@ void main() { game.add(component); await game.ready(); - expect(game.children.whereType().length, 1); - game.firstChild()!.onScaleStart( + expect(game.children.whereType().length, 1); + game.firstChild()!.onScaleStart( createScaleStartEvents( game: game, localFocalPoint: const Offset(12, 12), @@ -47,7 +46,7 @@ void main() { ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; dispatcher.onScaleStart( createScaleStartEvents( @@ -84,7 +83,7 @@ void main() { ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; dispatcher.onScaleStart( createScaleStartEvents( @@ -123,7 +122,7 @@ void main() { ..width = 10 ..height = 10; await game.ensureAdd(component); - final dispatcher = game.firstChild()!; + final dispatcher = game.firstChild()!; expect(component.scaleStartEvent, equals(0)); expect(component.scaleUpdateEvent, equals(0)); @@ -159,9 +158,9 @@ void main() { expect(game.children.length, equals(4)); expect(component.isMounted, isTrue); - expect(component.scaleStartEvent, equals(2)); + expect(component.scaleStartEvent, equals(1)); expect(component.scaleUpdateEvent, greaterThan(0)); - expect(component.scaleEndEvent, equals(2)); + expect(component.scaleEndEvent, equals(1)); }); testWidgets( @@ -193,7 +192,7 @@ void main() { (game) async { await game.ready(); expect(game.children.length, equals(3)); - expect(game.children.elementAt(1), isA()); + expect(game.children.elementAt(1), isA()); }, ); @@ -214,9 +213,9 @@ void main() { offset2: const Offset(-15, -2), ); - expect(game.scaleStartEvent, equals(2)); + expect(game.scaleStartEvent, equals(1)); expect(game.scaleUpdateEvent, greaterThan(0)); - expect(game.scaleEndEvent, equals(2)); + expect(game.scaleEndEvent, equals(1)); }, ); @@ -241,7 +240,7 @@ void main() { offset2: const Offset(-15, -2), ); - expect(component.isScaledStateChange, equals(4)); + expect(component.isScaledStateChange, equals(2)); // Outside component await tester.zoomFrom( @@ -251,7 +250,7 @@ void main() { offset2: const Offset(-15, -2), ); - expect(component.isScaledStateChange, equals(4)); + expect(component.isScaledStateChange, equals(2)); }, ); group('HasScalableComponents', () { @@ -408,66 +407,4 @@ void main() { } }, ); - - group('ScaleDispatcher lifecycle', () { - testWithFlameGame( - 'rejects new gestures after markForRemoval', - (game) async { - final component = ScaleCallbacksComponent() - ..x = 10 - ..y = 10 - ..width = 10 - ..height = 10; - await game.ensureAdd(component); - final dispatcher = game.firstChild()!; - - dispatcher.markForRemoval(); - dispatcher.handleScaleStart( - ScaleStartDetails(focalPoint: const Offset(12, 12)), - ); - expect(component.scaleStartEvent, 0); - }, - ); - - testWithFlameGame( - 'removes itself after last gesture ends when marked', - (game) async { - final component = ScaleCallbacksComponent() - ..x = 10 - ..y = 10 - ..width = 10 - ..height = 10; - await game.ensureAdd(component); - final dispatcher = game.firstChild()!; - - dispatcher.handleScaleStart( - ScaleStartDetails(focalPoint: const Offset(12, 12)), - ); - expect(component.scaleStartEvent, 1); - - dispatcher.markForRemoval(); - expect(dispatcher.isMounted, isTrue); - - dispatcher.handleScaleEnd(ScaleEndDetails()); - game.update(0); - expect(game.children.whereType().length, 0); - }, - ); - - testWithFlameGame( - 'can be recreated after removal (unregisterKey works)', - (game) async { - await game.ensureAdd(ScaleCallbacksComponent()); - expect(game.children.whereType().length, 1); - - final dispatcher = game.firstChild()!; - dispatcher.markForRemoval(); - game.update(0); - expect(game.children.whereType().length, 0); - - await game.ensureAdd(ScaleCallbacksComponent()); - expect(game.children.whereType().length, 1); - }, - ); - }); } 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 1af61798ee5..c50f4a52782 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 @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; 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/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -715,7 +714,7 @@ void main() { testWidgets( '''adding drag component after scale component - upgrade dispatcher to multiDragScaleDispatcher''', + upgrade dispatcher to multiDragMultiDragScaleDispatcher''', (tester) async { final resolution = Vector2(80, 60); final game = FlameGame( @@ -818,57 +817,28 @@ void main() { ); testWithFlameGame( - 'upgrade from ScaleDispatcher to MultiDragScaleDispatcher ' - 'marks old for removal', + 'adding DragCallbacks after ScaleCallbacks reuses the same dispatcher', (game) async { await game.ensureAdd(ScaleCallbacksComponent()); - expect(game.children.whereType().length, 1); + expect(game.children.whereType().length, 1); await game.ensureAdd(DragCallbacksComponent()); game.update(0); - expect( - game.children.whereType().length, - 1, - ); - expect(game.children.whereType().length, 0); + expect(game.children.whereType().length, 1); }, ); testWithFlameGame( - 'upgrade from MultiDragDispatcher to MultiDragScaleDispatcher ' - 'marks old for removal', + 'adding ScaleCallbacks after DragCallbacks reuses the same dispatcher', (game) async { await game.ensureAdd(DragCallbacksComponent()); - expect(game.children.whereType().length, 1); + expect(game.children.whereType().length, 1); await game.ensureAdd(ScaleCallbacksComponent()); game.update(0); - expect( - game.children.whereType().length, - 1, - ); - expect(game.children.whereType().length, 0); - }, - ); - - testWithFlameGame( - 'adding ScaleCallbacks after DragCallbacks creates ' - 'MultiDragScaleDispatcher with only one dispatcher', - (game) async { - await game.ensureAdd(DragCallbacksComponent()); - expect(game.children.whereType().length, 1); - - await game.ensureAdd(ScaleCallbacksComponent()); - game.update(0); - - expect( - game.children.whereType().length, - 1, - ); - expect(game.children.whereType().length, 0); - expect(game.children.whereType().length, 0); + expect(game.children.whereType().length, 1); }, ); }); From 76980252d04ec67ec971faad0d4b61061d275272 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 18:56:34 +0200 Subject: [PATCH 73/87] fix: Update DynamicScaleDrag example to remove deleted dispatcher references --- .../input/dynamic_scale_drag_example.dart | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/examples/lib/stories/input/dynamic_scale_drag_example.dart b/examples/lib/stories/input/dynamic_scale_drag_example.dart index 1feb4ca57e0..d8c0982cea0 100644 --- a/examples/lib/stories/input/dynamic_scale_drag_example.dart +++ b/examples/lib/stories/input/dynamic_scale_drag_example.dart @@ -117,25 +117,10 @@ class DynamicScaleDragExample extends FlameGame { @override void update(double dt) { super.update(dt); - final dispatchers = []; - if (children.whereType().isNotEmpty) { - dispatchers.add('MultiDragScaleDispatcher'); - } - if (children.whereType().isNotEmpty) { - dispatchers.add('MultiDragDispatcher'); - } - // ScaleDispatcher is not publicly exported, so detect it by exclusion. - final otherDispatchers = children.where( - (c) => c is! MultiDragScaleDispatcher && c is! MultiDragDispatcher, - ); - for (final c in otherDispatchers) { - final name = c.runtimeType.toString(); - if (name == 'ScaleDispatcher') { - dispatchers.add('ScaleDispatcher'); - } - } - dispatcherLabel.text = - 'Dispatcher: ${dispatchers.isEmpty ? 'none' : dispatchers.join(', ')}'; + final active = children.whereType().isNotEmpty; + dispatcherLabel.text = active + ? 'Dispatcher: MultiDragScaleDispatcher' + : 'Dispatcher: none'; } } From e619fb9a9235b6d892f17253232dc20614a8aefb Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 18:57:30 +0200 Subject: [PATCH 74/87] fix: Remove dispatcher label from DynamicScaleDrag example --- .../input/dynamic_scale_drag_example.dart | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/examples/lib/stories/input/dynamic_scale_drag_example.dart b/examples/lib/stories/input/dynamic_scale_drag_example.dart index d8c0982cea0..35e56bc35c7 100644 --- a/examples/lib/stories/input/dynamic_scale_drag_example.dart +++ b/examples/lib/stories/input/dynamic_scale_drag_example.dart @@ -16,19 +16,8 @@ class DynamicScaleDragExample extends FlameGame { The system seamlessly handles mixing different interaction types. '''; - late TextComponent dispatcherLabel; - @override Future onLoad() async { - dispatcherLabel = TextComponent( - text: 'Dispatcher: none', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 18, color: Colors.white), - ), - position: Vector2(10, 10), - ); - camera.viewport.add(dispatcherLabel); - camera.viewport.add( _Button( text: '+ Drag', @@ -113,15 +102,6 @@ class DynamicScaleDragExample extends FlameGame { (rng.nextDouble() - 0.5) * 200, ); } - - @override - void update(double dt) { - super.update(dt); - final active = children.whereType().isNotEmpty; - dispatcherLabel.text = active - ? 'Dispatcher: MultiDragScaleDispatcher' - : 'Dispatcher: none'; - } } class _Button extends PositionComponent with TapCallbacks { From b00f105720e38fcbcbec3b9cbbda70fb970b97f0 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 19:06:13 +0200 Subject: [PATCH 75/87] docs: Remove Dynamic addition sections from drag and scale event docs --- doc/flame/inputs/drag_events.md | 35 -------------- doc/flame/inputs/scale_events.md | 83 -------------------------------- 2 files changed, 118 deletions(-) diff --git a/doc/flame/inputs/drag_events.md b/doc/flame/inputs/drag_events.md index 4997b55486f..2bb091fa500 100644 --- a/doc/flame/inputs/drag_events.md +++ b/doc/flame/inputs/drag_events.md @@ -149,38 +149,3 @@ class InteractiveRect extends RectangleComponent } } ``` - - -### Dynamic addition - -Components with different callback types can be added to the game at any time. For example, you can -start with only `DragCallbacks` components and later add a `ScaleCallbacks` component. Flame will -automatically reconfigure the gesture handling so that both types work correctly. Any gestures that -are already in progress (e.g. an ongoing drag) will continue uninterrupted during this transition. - -See also [Scale Events — Combining with DragCallbacks](scale_events.md#combining-with-dragcallbacks). - - -```dart -class MyComponent extends PositionComponent with DragCallbacks { - MyComponent({super.size}); - - final _paint = Paint(); - bool _isDragged = false; - - @override - void onDragStart(DragStartEvent event) => _isDragged = true; - - @override - void onDragUpdate(DragUpdateEvent event) => position += event.delta; - - @override - void onDragEnd(DragEndEvent event) => _isDragged = false; - - @override - void render(Canvas canvas) { - _paint.color = _isDragged? Colors.red : Colors.white; - canvas.drawRect(size.toRect(), _paint); - } -} -``` diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index a3592126676..e1278d707e3 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -137,86 +137,3 @@ class InteractiveRect extends RectangleComponent } } ``` - - -### Dynamic addition - -Components with different callback types can be added to the game at any time. For example, you can -start with only `DragCallbacks` components and later add a `ScaleCallbacks` component. Flame will -automatically reconfigure the gesture handling so that both types work correctly. Any gestures that -are already in progress (e.g. an ongoing drag) will continue uninterrupted during this transition. - -See also [Drag Events — Combining with ScaleCallbacks](drag_events.md#combining-with-scalecallbacks). - - -```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, - ); - - @override - Future onLoad() async { - final text = TextComponent( - text: 'scale', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size / 2, - anchor: Anchor.center, - ); - add(text); - } - - bool 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}'); - } - - @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}'); - } -} - -``` From 14989562c0aceb7c5903249362ede215ea376667 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 19:06:59 +0200 Subject: [PATCH 76/87] chore: Remove superseded scale_drag_example --- .../lib/stories/input/scale_drag_example.dart | 290 ------------------ 1 file changed, 290 deletions(-) delete mode 100644 examples/lib/stories/input/scale_drag_example.dart diff --git a/examples/lib/stories/input/scale_drag_example.dart b/examples/lib/stories/input/scale_drag_example.dart deleted file mode 100644 index a3dfcf87e3c..00000000000 --- a/examples/lib/stories/input/scale_drag_example.dart +++ /dev/null @@ -1,290 +0,0 @@ -import 'dart:math'; -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame/game.dart'; -import 'package:flutter/material.dart'; - -void main() { - runApp(GameWidget(game: ScaleExample())); -} - -class ScaleExample extends FlameGame { - late RectangleComponent rect; - late TextComponent debugText; - - late InteractiveRectangle interactiveRectangle; - Vector2 zoomCenter = Vector2.zero(); - double startingZoom = 1; - - final bool addScaleOnlyRectangle = false; - final bool addDragOnlyRectangle = true; - final bool addScaleDragRectangle = true; - final bool addZoom = false; - final bool addCameraRotation = false; - - @override - Future onLoad() async { - camera.viewfinder.zoom = 1; - - debugText = TextComponent( - text: 'hello', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: Vector2.all(50), - ); - - if (addScaleOnlyRectangle) { - final scaleOnlyRectangle = ScaleOnlyRectangle( - position: Vector2.zero(), - size: Vector2.all(150), - ); - world.add(scaleOnlyRectangle); - } - if (addDragOnlyRectangle) { - final dragOnlyRectangle = DragOnlyRectangle( - position: Vector2.all(-200), - size: Vector2.all(150), - color: Colors.green, - ); - world.add(dragOnlyRectangle); - } - - if (addScaleDragRectangle) { - interactiveRectangle = InteractiveRectangle( - position: Vector2.all(100), - size: Vector2.all(150), - color: Colors.red, - ); - world.add(interactiveRectangle); - } - - camera.viewport.add(debugText); - } - - @override - void update(double dt) { - super.update(dt); - - if (addCameraRotation) { - camera.viewfinder.angle += 0.1 * dt; - } - if (addZoom) { - debugText.text = '${camera.viewfinder.zoom}'; - camera.viewfinder.zoom += 0.1 * dt; - } - } -} - -/// A rectangle component that can respond to both drag and scale gestures. -class InteractiveRectangle extends RectangleComponent - with ScaleCallbacks, DragCallbacks, HasGameReference { - InteractiveRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); - - bool isDoingScaling = false; - double initialAngle = 0; - Vector2 initialScale = Vector2.all(1); - double lastScale = 1.0; - - @override - Future onLoad() async { - final text = TextComponent( - text: 'drag + scale', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size / 2, - anchor: Anchor.center, - ); - add(text); - } - - /// DragCallbacks overrides - @override - void onDragStart(DragStartEvent event) { - super.onDragStart(event); - debugPrint('Drag started at ${event.devicePosition}'); - } - - @override - void onDragUpdate(DragUpdateEvent event) { - super.onDragUpdate(event); - final rotated = event.canvasDelta.clone() - ..rotate(game.camera.viewfinder.angle); - position.add(rotated); - } - - @override - void onDragEnd(DragEndEvent event) { - super.onDragEnd(event); - debugPrint('Drag ended with velocity ${event.velocity}'); - } - - /// ScaleCallbacks overrides - @override - void onScaleStart(ScaleStartEvent event) { - super.onScaleStart(event); - isDoingScaling = true; - initialAngle = angle; - initialScale = scale; - lastScale = 1.0; - debugPrint('Scale started at ${event.devicePosition}'); - } - - @override - void onScaleUpdate(ScaleUpdateEvent event) { - super.onScaleUpdate(event); - // scale rectangle size by pinch - angle = initialAngle + event.rotation; - - // delta scale since last frame - if (lastScale == 0) { - return; - } - final scaleDelta = event.scale / lastScale; - lastScale = event.scale; // update for next frame - - // apply delta gently - scale *= sqrt(scaleDelta); - - // clamp - scale.clamp(Vector2.all(0.8), Vector2.all(3)); - } - - @override - void onScaleEnd(ScaleEndEvent event) { - super.onScaleEnd(event); - isDoingScaling = false; - debugPrint('Scale ended with velocity ${event.velocity}'); - } -} - -/// A rectangle that only responds to drag -class DragOnlyRectangle extends RectangleComponent - with DragCallbacks, HasGameReference { - DragOnlyRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); - - @override - Future onLoad() async { - final text = TextComponent( - text: 'drag', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size / 2, - anchor: Anchor.center, - ); - add(text); - } - - /// DragCallbacks overrides - @override - void onDragStart(DragStartEvent event) { - super.onDragStart(event); - debugPrint('Drag started at ${event.devicePosition}'); - } - - @override - void onDragUpdate(DragUpdateEvent event) { - super.onDragUpdate(event); - final rotated = event.canvasDelta.clone() - ..rotate(game.camera.viewfinder.angle); - position.add(rotated); - } - - @override - void onDragEnd(DragEndEvent event) { - super.onDragEnd(event); - debugPrint('Drag ended with velocity ${event.velocity}'); - } -} - -/// A rectangle that only responds to scale -class ScaleOnlyRectangle extends RectangleComponent with ScaleCallbacks { - ScaleOnlyRectangle({ - required Vector2 position, - required Vector2 size, - Color color = Colors.blue, - Anchor anchor = Anchor.center, - }) : super( - position: position, - size: size, - anchor: anchor, - paint: Paint()..color = color, - ); - - @override - Future onLoad() async { - final text = TextComponent( - text: 'scale', - textRenderer: TextPaint( - style: const TextStyle(fontSize: 25, color: Colors.white), - ), - position: size / 2, - anchor: Anchor.center, - ); - add(text); - } - - bool isDoingScaling = false; - double initialAngle = 0; - Vector2 initialScale = Vector2.all(1); - double lastScale = 1.0; - - /// ScaleCallbacks overrides - @override - void onScaleStart(ScaleStartEvent event) { - super.onScaleStart(event); - isDoingScaling = true; - initialAngle = angle; - initialScale = scale; - lastScale = 1.0; - debugPrint('Scale started at ${event.devicePosition}'); - } - - @override - void onScaleUpdate(ScaleUpdateEvent event) { - super.onScaleUpdate(event); - // scale rectangle size by pinch - angle = initialAngle + event.rotation; - // delta scale since last frame - if (lastScale == 0) { - return; - } - final scaleDelta = event.scale / lastScale; - lastScale = event.scale; // update for next frame - - // apply delta gently - scale *= sqrt(scaleDelta); - - // clamp - scale.clamp(Vector2.all(0.8), Vector2.all(3)); - } - - @override - void onScaleEnd(ScaleEndEvent event) { - super.onScaleEnd(event); - isDoingScaling = false; - debugPrint('Scale ended with velocity ${event.velocity}'); - } -} From e442e1cbbe9bb578b0438b75f7e48c83d058b398 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 19:20:41 +0200 Subject: [PATCH 77/87] fix: Disable recognizer flags when last DragCallbacks/ScaleCallbacks 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. --- .../component_mixins/drag_callbacks.dart | 11 ++++ .../component_mixins/scale_callbacks.dart | 16 ++++++ .../scale_drag_dispatcher.dart | 51 +++++++++++++++++++ .../component_mixins/drag_callbacks_test.dart | 37 ++++++++++++++ .../scale_callbacks_test.dart | 37 ++++++++++++++ 5 files changed, 152 insertions(+) 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 1ff965131dd..80b9b1f227c 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -69,4 +69,15 @@ mixin DragCallbacks on Component { hasScale: this is ScaleCallbacks, ); } + + @override + @mustCallSuper + void onRemove() { + MultiDragScaleDispatcher.removeDispatcher( + this, + hasDrag: true, + hasScale: this is ScaleCallbacks, + ); + super.onRemove(); + } } diff --git a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart index 4428fe0d5c6..d767360f985 100644 --- a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart @@ -36,4 +36,20 @@ mixin ScaleCallbacks on Component { hasScale: true, ); } + + @override + @mustCallSuper + void onRemove() { + // DragCallbacks.onRemove handles the full cleanup for combined components. + if (this is DragCallbacks) { + super.onRemove(); + return; + } + MultiDragScaleDispatcher.removeDispatcher( + this, + hasDrag: false, + hasScale: true, + ); + super.onRemove(); + } } 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 index f3ac50035b9..cbdcf5ddb40 100644 --- 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 @@ -34,14 +34,23 @@ class MultiDragScaleDispatcher extends Component bool _hasDrag = false; bool _hasScale = false; + int _dragCount = 0; + int _scaleCount = 0; MultiDragScaleGestureRecognizer? _recognizer; + @visibleForTesting + bool get hasDrag => _hasDrag; + + @visibleForTesting + bool get hasScale => _hasScale; + FlameGame get game => parent! as FlameGame; /// Enables drag forwarding on the underlying recognizer. /// /// Safe to call before or after [onMount]. void enableDrag() { + _dragCount++; _hasDrag = true; _recognizer?.hasDrag = true; } @@ -50,10 +59,27 @@ class MultiDragScaleDispatcher extends Component /// /// Safe to call before or after [onMount]. void enableScale() { + _scaleCount++; _hasScale = true; _recognizer?.hasScale = true; } + void _disableDrag() { + _dragCount--; + if (_dragCount == 0) { + _hasDrag = false; + _recognizer?.hasDrag = false; + } + } + + void _disableScale() { + _scaleCount--; + if (_scaleCount == 0) { + _hasScale = false; + _recognizer?.hasScale = false; + } + } + /// Ensures a [MultiDragScaleDispatcher] is registered on the game that owns /// [component], then enables drag and/or scale as requested. static void addDispatcher( @@ -78,6 +104,31 @@ class MultiDragScaleDispatcher extends Component } } + /// Decrements the reference counts for [component]'s event types and + /// disables the corresponding recognizer flags when the count reaches zero. + static void removeDispatcher( + Component component, { + required bool hasDrag, + required bool hasScale, + }) { + final game = component.findRootGame(); + if (game == null) { + return; + } + final dispatcher = + game.findByKey(const MultiDragScaleDispatcherKey()) + as MultiDragScaleDispatcher?; + if (dispatcher == null) { + return; + } + if (hasDrag) { + dispatcher._disableDrag(); + } + if (hasScale) { + dispatcher._disableScale(); + } + } + /// Called when the user initiates a drag gesture, for example by touching the /// screen and then moving the finger. /// 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 2e819e8a986..d52b1985605 100644 --- a/packages/flame/test/events/component_mixins/drag_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/drag_callbacks_test.dart @@ -18,6 +18,43 @@ void main() { }, ); + testWithFlameGame( + 'removing the last DragCallbacks component disables hasDrag on the ' + 'recognizer', + (game) async { + final component = DragCallbacksComponent()..size = Vector2.all(10); + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + expect(dispatcher.hasDrag, isTrue); + + game.remove(component); + await game.ready(); + + expect(dispatcher.hasDrag, isFalse); + }, + ); + + testWithFlameGame( + 'hasDrag stays true while at least one DragCallbacks component remains', + (game) async { + final a = DragCallbacksComponent()..size = Vector2.all(10); + final b = DragCallbacksComponent()..size = Vector2.all(10); + await game.ensureAdd(a); + await game.ensureAdd(b); + final dispatcher = game.firstChild()!; + + game.remove(a); + await game.ready(); + + expect(dispatcher.hasDrag, isTrue); + + game.remove(b); + await game.ready(); + + expect(dispatcher.hasDrag, isFalse); + }, + ); + testWithFlameGame('drag event start', (game) async { final component = DragCallbacksComponent() ..x = 10 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 def3b419e44..29d2bd78244 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -18,6 +18,43 @@ void main() { expect(game.children.toList()[2], isA()); }, ); + + testWithFlameGame( + 'removing the last ScaleCallbacks component disables hasScale on the ' + 'recognizer', + (game) async { + final component = ScaleCallbacksComponent()..size = Vector2.all(10); + await game.ensureAdd(component); + final dispatcher = game.firstChild()!; + expect(dispatcher.hasScale, isTrue); + + game.remove(component); + await game.ready(); + + expect(dispatcher.hasScale, isFalse); + }, + ); + + testWithFlameGame( + 'hasScale stays true while at least one ScaleCallbacks component remains', + (game) async { + final a = ScaleCallbacksComponent()..size = Vector2.all(10); + final b = ScaleCallbacksComponent()..size = Vector2.all(10); + await game.ensureAdd(a); + await game.ensureAdd(b); + final dispatcher = game.firstChild()!; + + game.remove(a); + await game.ready(); + + expect(dispatcher.hasScale, isTrue); + + game.remove(b); + await game.ready(); + + expect(dispatcher.hasScale, isFalse); + }, + ); }); testWithFlameGame('scale event start', (game) async { final component = ScaleCallbacksComponent() From 43defb40e51970cbb7986db645d590c9689bdae2 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 17 May 2026 20:50:08 +0200 Subject: [PATCH 78/87] refactor: Address review feedback on recognizer-v2 PR - Fix dartdoc reference for MultiDragScaleDispatcher in TaggedComponent - Remove redundant _hasDrag/_hasScale copies; use _dragCount > 0 as source of truth - Extend Dispatcher 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 ! --- .../component_mixins/drag_callbacks.dart | 4 ++-- .../component_mixins/scale_callbacks.dart | 10 --------- .../scale_drag_dispatcher.dart | 19 +++++----------- .../events/multi_drag_scale_recognizer.dart | 22 ++++++++++--------- .../lib/src/events/tagged_component.dart | 2 +- 5 files changed, 21 insertions(+), 36 deletions(-) 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 80b9b1f227c..bb05312471f 100644 --- a/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart +++ b/packages/flame/lib/src/events/component_mixins/drag_callbacks.dart @@ -66,7 +66,7 @@ mixin DragCallbacks on Component { MultiDragScaleDispatcher.addDispatcher( this, hasDrag: true, - hasScale: this is ScaleCallbacks, + hasScale: false, ); } @@ -76,7 +76,7 @@ mixin DragCallbacks on Component { MultiDragScaleDispatcher.removeDispatcher( this, hasDrag: true, - hasScale: this is ScaleCallbacks, + hasScale: false, ); super.onRemove(); } diff --git a/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart b/packages/flame/lib/src/events/component_mixins/scale_callbacks.dart index d767360f985..3481232abf0 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,6 @@ mixin ScaleCallbacks on Component { @mustCallSuper void onMount() { super.onMount(); - // DragCallbacks.onMount handles the full registration for - // combined components. - if (this is DragCallbacks) { - return; - } MultiDragScaleDispatcher.addDispatcher( this, hasDrag: false, @@ -40,11 +35,6 @@ mixin ScaleCallbacks on Component { @override @mustCallSuper void onRemove() { - // DragCallbacks.onRemove handles the full cleanup for combined components. - if (this is DragCallbacks) { - super.onRemove(); - return; - } MultiDragScaleDispatcher.removeDispatcher( this, hasDrag: false, 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 index cbdcf5ddb40..1aac677e159 100644 --- 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 @@ -1,6 +1,7 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/src/events/flame_drag_adapter.dart'; +import 'package:flame/src/events/flame_game_mixins/dispatcher.dart'; import 'package:flame/src/events/interfaces/scale_listener.dart'; import 'package:flame/src/events/multi_drag_scale_recognizer.dart'; import 'package:flame/src/events/tagged_component.dart'; @@ -27,31 +28,26 @@ class MultiDragScaleDispatcherKey implements ComponentKey { /// Use [enableDrag] and [enableScale] (called via [addDispatcher]) to control /// which event types are forwarded to the underlying /// [MultiDragScaleGestureRecognizer]. -class MultiDragScaleDispatcher extends Component +class MultiDragScaleDispatcher extends Dispatcher implements MultiDragListener, ScaleListener { /// The record of all components currently being touched. final Set> _records = {}; - bool _hasDrag = false; - bool _hasScale = false; int _dragCount = 0; int _scaleCount = 0; MultiDragScaleGestureRecognizer? _recognizer; @visibleForTesting - bool get hasDrag => _hasDrag; + bool get hasDrag => _dragCount > 0; @visibleForTesting - bool get hasScale => _hasScale; - - FlameGame get game => parent! as FlameGame; + bool get hasScale => _scaleCount > 0; /// Enables drag forwarding on the underlying recognizer. /// /// Safe to call before or after [onMount]. void enableDrag() { _dragCount++; - _hasDrag = true; _recognizer?.hasDrag = true; } @@ -60,14 +56,12 @@ class MultiDragScaleDispatcher extends Component /// Safe to call before or after [onMount]. void enableScale() { _scaleCount++; - _hasScale = true; _recognizer?.hasScale = true; } void _disableDrag() { _dragCount--; if (_dragCount == 0) { - _hasDrag = false; _recognizer?.hasDrag = false; } } @@ -75,7 +69,6 @@ class MultiDragScaleDispatcher extends Component void _disableScale() { _scaleCount--; if (_scaleCount == 0) { - _hasScale = false; _recognizer?.hasScale = false; } } @@ -358,8 +351,8 @@ class MultiDragScaleDispatcher extends Component MultiDragScaleGestureRecognizer.new, (MultiDragScaleGestureRecognizer instance) { _recognizer = instance; - instance.hasDrag = _hasDrag; - instance.hasScale = _hasScale; + instance.hasDrag = _dragCount > 0; + instance.hasScale = _scaleCount > 0; instance.onStart = (Offset point) => FlameDragAdapter(this, point); instance.onScaleStart = handleScaleStart; instance.onScaleUpdate = handleScaleUpdate; diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 3c65abb15de..0c0472a73ab 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -325,16 +325,18 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { double _computeRotationFactor() { var factor = 0.0; - if (_scale.initialLine != null && _scale.currentLine != null) { - final fx = _scale.initialLine!.pointerStartLocation.dx; - final fy = _scale.initialLine!.pointerStartLocation.dy; - final sx = _scale.initialLine!.pointerEndLocation.dx; - final sy = _scale.initialLine!.pointerEndLocation.dy; - - final nfx = _scale.currentLine!.pointerStartLocation.dx; - final nfy = _scale.currentLine!.pointerStartLocation.dy; - final nsx = _scale.currentLine!.pointerEndLocation.dx; - final nsy = _scale.currentLine!.pointerEndLocation.dy; + final initialLine = _scale.initialLine; + final currentLine = _scale.currentLine; + if (initialLine != null && currentLine != null) { + final fx = initialLine.pointerStartLocation.dx; + final fy = initialLine.pointerStartLocation.dy; + final sx = initialLine.pointerEndLocation.dx; + final sy = initialLine.pointerEndLocation.dy; + + final nfx = currentLine.pointerStartLocation.dx; + final nfy = currentLine.pointerStartLocation.dy; + final nsx = currentLine.pointerEndLocation.dx; + final nsy = currentLine.pointerEndLocation.dy; final angle1 = math.atan2(fy - sy, fx - sx); final angle2 = math.atan2(nfy - nsy, nfx - nsx); diff --git a/packages/flame/lib/src/events/tagged_component.dart b/packages/flame/lib/src/events/tagged_component.dart index 0450dcfeb73..162d93bfc09 100644 --- a/packages/flame/lib/src/events/tagged_component.dart +++ b/packages/flame/lib/src/events/tagged_component.dart @@ -5,7 +5,7 @@ import 'package:meta/meta.dart'; /// [TaggedComponent] is a utility class that represents a pair of a component /// and a pointer id. /// -/// This class is used by [MultiTapDispatcher] and MultiDragScaleDispatcher +/// This class is used by [MultiTapDispatcher] and [MultiDragScaleDispatcher] /// to store information about which components were affected by which pointer /// event, so that subsequent events can be reliably delivered to the same /// components. From 5dee1db945e757e6b4c31958d5893ecee59edf17 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 18 May 2026 16:10:58 +0200 Subject: [PATCH 79/87] refactor: Address second round of review feedback - Fix flutter/material.dart import to flutter/gestures.dart in scale_drag_dispatcher.dart - Add docImport for MultiDragScaleDispatcher in tagged_component.dart - Add deprecated aliases for MultiDragDispatcher and MultiDragDispatcherKey via deprecated.dart - Fix velocity tracker to use focal point position instead of scale factor - Remove scaleVelocity from ScaleEndDetails (not tracked separately) - Add early return in addAllowedPointer when both hasDrag and hasScale are false - Fix doc samples to declare _initialAngle field and assign it in onScaleStart --- doc/flame/inputs/drag_events.md | 6 ++++-- doc/flame/inputs/scale_events.md | 6 ++++-- packages/flame/lib/events.dart | 2 ++ packages/flame/lib/src/events/deprecated.dart | 7 +++++++ .../flame_game_mixins/scale_drag_dispatcher.dart | 2 +- .../lib/src/events/multi_drag_scale_recognizer.dart | 12 +++++------- packages/flame/lib/src/events/tagged_component.dart | 1 + 7 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 packages/flame/lib/src/events/deprecated.dart diff --git a/doc/flame/inputs/drag_events.md b/doc/flame/inputs/drag_events.md index 2bb091fa500..0378318f6e4 100644 --- a/doc/flame/inputs/drag_events.md +++ b/doc/flame/inputs/drag_events.md @@ -132,6 +132,8 @@ pinch-to-zoom or rotatable with two fingers. class InteractiveRect extends RectangleComponent with ScaleCallbacks, DragCallbacks { + double _initialAngle = 0; + @override void onDragUpdate(DragUpdateEvent event) { position += event.localDelta; @@ -140,12 +142,12 @@ class InteractiveRect extends RectangleComponent @override void onScaleStart(ScaleStartEvent event) { super.onScaleStart(event); - // store initial angle/scale for relative updates + _initialAngle = angle; } @override void onScaleUpdate(ScaleUpdateEvent event) { - angle = initialAngle + event.rotation; + angle = _initialAngle + event.rotation; } } ``` diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index e1278d707e3..54c77548f57 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -120,6 +120,8 @@ pinch-to-zoom or rotatable with two fingers. class InteractiveRect extends RectangleComponent with ScaleCallbacks, DragCallbacks { + double _initialAngle = 0; + @override void onDragUpdate(DragUpdateEvent event) { position += event.localDelta; @@ -128,12 +130,12 @@ class InteractiveRect extends RectangleComponent @override void onScaleStart(ScaleStartEvent event) { super.onScaleStart(event); - // store initial angle/scale for relative updates + _initialAngle = angle; } @override void onScaleUpdate(ScaleUpdateEvent event) { - angle = initialAngle + event.rotation; + angle = _initialAngle + event.rotation; } } ``` diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 3ede2c29060..c521b22f328 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -13,6 +13,8 @@ export 'src/events/component_mixins/secondary_tap_callbacks.dart' export 'src/events/component_mixins/tap_callbacks.dart' show TapCallbacks; export 'src/events/component_mixins/tertiary_tap_callbacks.dart' show TertiaryTapCallbacks; +export 'src/events/deprecated.dart' + show MultiDragDispatcher, MultiDragDispatcherKey; export 'src/events/flame_game_mixins/double_tap_dispatcher.dart' show DoubleTapDispatcher, DoubleTapDispatcherKey; export 'src/events/flame_game_mixins/long_press_dispatcher.dart' diff --git a/packages/flame/lib/src/events/deprecated.dart b/packages/flame/lib/src/events/deprecated.dart new file mode 100644 index 00000000000..2c5bda16f2f --- /dev/null +++ b/packages/flame/lib/src/events/deprecated.dart @@ -0,0 +1,7 @@ +import 'package:flame/src/events/flame_game_mixins/scale_drag_dispatcher.dart'; + +@Deprecated('Use MultiDragScaleDispatcher instead.') +typedef MultiDragDispatcher = MultiDragScaleDispatcher; + +@Deprecated('Use MultiDragScaleDispatcherKey instead.') +typedef MultiDragDispatcherKey = MultiDragScaleDispatcherKey; diff --git a/packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart b/packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart index 1aac677e159..e2fc358bd2d 100644 --- 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 @@ -7,7 +7,7 @@ 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:flutter/gestures.dart'; import 'package:meta/meta.dart'; class MultiDragScaleDispatcherKey implements ComponentKey { diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 0c0472a73ab..fc566fa40fd 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -57,6 +57,9 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { @override void addAllowedPointer(PointerDownEvent event) { + if (!hasDrag && !hasScale) { + return; + } assert(!_drag.pointers.containsKey(event.pointer)); final state = _DragPointerState(recognizer: this, event: event); _drag.pointers[event.pointer] = state; @@ -155,7 +158,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { if (_scale.active && _drag.count >= 2) { _scale.velocityTracker?.addPosition( event.timeStamp, - Offset(_scale.scaleFactor, 0), + _scale.currentFocalPoint!, ); if (onScaleUpdate != null) { @@ -199,7 +202,6 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { () => onScaleEnd!( ScaleEndDetails( velocity: clampedVelocity, - scaleVelocity: velocity.pixelsPerSecond.dx, pointerCount: pointerCount, ), ), @@ -210,7 +212,6 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { () => onScaleEnd!( ScaleEndDetails( velocity: velocity, - scaleVelocity: velocity.pixelsPerSecond.dx, pointerCount: pointerCount, ), ), @@ -220,10 +221,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { invokeCallback( 'onScaleEnd', () => onScaleEnd!( - ScaleEndDetails( - scaleVelocity: velocity.pixelsPerSecond.dx, - pointerCount: pointerCount, - ), + ScaleEndDetails(pointerCount: pointerCount), ), ); } diff --git a/packages/flame/lib/src/events/tagged_component.dart b/packages/flame/lib/src/events/tagged_component.dart index 162d93bfc09..0420027dcde 100644 --- a/packages/flame/lib/src/events/tagged_component.dart +++ b/packages/flame/lib/src/events/tagged_component.dart @@ -1,5 +1,6 @@ import 'package:flame/src/components/core/component.dart'; import 'package:flame/src/events/flame_game_mixins/multi_tap_dispatcher.dart'; +import 'package:flame/src/events/flame_game_mixins/scale_drag_dispatcher.dart'; import 'package:meta/meta.dart'; /// [TaggedComponent] is a utility class that represents a pair of a component From 968bd099c20ce25fbfb2e8cf8094fbee804ff1de Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 18 May 2026 17:01:23 +0200 Subject: [PATCH 80/87] fix: Address two review issues in recognizer Add asserts to guard against counter underflow in _disableDrag/_disableScale. Gate onScaleStart/onScaleUpdate activation on the scale threshold being exceeded, so scale callbacks only fire once the gesture passes the threshold check. --- .../flame_game_mixins/scale_drag_dispatcher.dart | 2 ++ .../lib/src/events/multi_drag_scale_recognizer.dart | 12 +++++------- 2 files changed, 7 insertions(+), 7 deletions(-) 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 index e2fc358bd2d..09de9077573 100644 --- 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 @@ -60,6 +60,7 @@ class MultiDragScaleDispatcher extends Dispatcher } void _disableDrag() { + assert(_dragCount > 0, '_disableDrag called more times than enableDrag'); _dragCount--; if (_dragCount == 0) { _recognizer?.hasDrag = false; @@ -67,6 +68,7 @@ class MultiDragScaleDispatcher extends Dispatcher } void _disableScale() { + assert(_scaleCount > 0, '_disableScale called more times than enableScale'); _scaleCount--; if (_scaleCount == 0) { _recognizer?.hasScale = false; diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index fc566fa40fd..2b7f99d88c2 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -127,11 +127,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _updateScaleFields(); _updateLines(); - if (_drag.count >= 2 && !_scale.active) { - _checkScaleGestureThreshold(); - } - - if (!_scale.active && _drag.count >= 2) { + if (_drag.count >= 2 && !_scale.active && _checkScaleGestureThreshold()) { _scale.active = true; _scale.initialFocalPoint = _scale.currentFocalPoint; _scale.initialSpan = _scale.currentSpan; @@ -230,9 +226,9 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _scale.reset(); } - void _checkScaleGestureThreshold() { + bool _checkScaleGestureThreshold() { if (_drag.pointers.isEmpty || _scale.initialFocalPoint == null) { - return; + return false; } final spanDelta = (_scale.currentSpan - _scale.initialSpan).abs(); @@ -246,7 +242,9 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { state._arenaEntry?.resolve(GestureDisposition.accepted); } } + return true; } + return false; } void _updateScaleFields() { From 511043b6fa68c4c530bb91fd15fb6bd70add480d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 18 May 2026 17:44:02 +0200 Subject: [PATCH 81/87] fix: Measure scale from original finger positions, not threshold crossing point Removing the initialSpan/initialLine resets from the scale activation block means scale factor and rotation are measured from when fingers first touched down, not from when the threshold was crossed. This gives more intuitive semantics (first callback has scale > 1.0) and fixes the test expectations to match. --- .../lib/src/events/multi_drag_scale_recognizer.dart | 5 ----- .../events/component_mixins/scale_callbacks_test.dart | 9 +++------ .../component_mixins/scale_drag_callbacks_test.dart | 9 +++------ 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 2b7f99d88c2..b2cc47c675c 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -129,11 +129,6 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { if (_drag.count >= 2 && !_scale.active && _checkScaleGestureThreshold()) { _scale.active = true; - _scale.initialFocalPoint = _scale.currentFocalPoint; - _scale.initialSpan = _scale.currentSpan; - _scale.initialHorizontalSpan = _scale.currentHorizontalSpan; - _scale.initialVerticalSpan = _scale.currentVerticalSpan; - _scale.initialLine = _scale.currentLine; _scale.initialEventTimestamp = event.timeStamp; _scale.velocityTracker = VelocityTracker.withKind(event.kind); 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 29d2bd78244..b90245a310f 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -346,7 +346,7 @@ void main() { intervals: intervals, ); - expect(nEvents, intervals * 2 + 2); + expect(nEvents, intervals * 2 - 1); }, ); }); @@ -390,7 +390,7 @@ void main() { intervals: 10, ); - expect(scales.skip(1), List.generate(21, (i) => i + 1)); + expect(scales, List.generate(20, (i) => i + 2)); }, ); @@ -435,10 +435,7 @@ void main() { // computation of angle using trigonometry with triangle having a size // of length 1 and one of length i. - final expected = List.generate(21, (i) => -atan(i)); - - // remove the first element that is registered twice in the simulation - rotations = rotations.sublist(1); + final expected = List.generate(20, (i) => -atan(i + 1)); for (var i = 0; i < expected.length; i++) { expect(rotations[i], closeTo(expected[i], 1e-6)); // tolerance } 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 c50f4a52782..7c09b48cefe 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 @@ -431,7 +431,7 @@ void main() { const Duration(milliseconds: 300), intervals: intervals, ); - expect(nScaleEvents, intervals * 2 + 2); + expect(nScaleEvents, intervals * 2 - 1); expect(nDragEvents, intervals * 2 + 2); }, ); @@ -513,7 +513,7 @@ void main() { intervals: 10, ); - expect(scales.skip(1), List.generate(21, (i) => i + 1)); + expect(scales, List.generate(20, (i) => i + 2)); }, ); @@ -558,10 +558,7 @@ void main() { // computation of angle using trigonometry with triangle having a size // of length 1 and one of length i. - final expected = List.generate(21, (i) => -atan(i)); - - // remove the first element that is registered twice in the simulation - rotations = rotations.sublist(1); + final expected = List.generate(20, (i) => -atan(i + 1)); for (var i = 0; i < expected.length; i++) { expect(rotations[i], closeTo(expected[i], 1e-6)); } From 9d5bfcf8d54e215d6bdb7f172a1692e1adb0d20e Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 18 May 2026 17:46:17 +0200 Subject: [PATCH 82/87] docs: Document scaleThreshold and add configurable field to dispatcher Add a scaleThreshold field to MultiDragScaleDispatcher so users can tune the sensitivity of scale gesture recognition without subclassing. Add documentation explaining the threshold behavior and how to change it. --- doc/flame/inputs/scale_events.md | 25 +++++++++++++++++++ .../scale_drag_dispatcher.dart | 8 +++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 54c77548f57..3fbd0704048 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -109,6 +109,31 @@ actively being scaled. This is set to `true` at the start of `onScaleStart` and gesture. +### scaleThreshold + +Scale events are not fired immediately when two fingers touch the screen. Instead, a small movement +threshold must be crossed first. By default, the fingers must spread or pinch by at least 5% (a +scale factor of 1.05) before `onScaleStart` is called. This prevents accidental scale gestures when +the user simply places two fingers without intending to scale. + +The threshold can be changed by accessing the `MultiDragScaleDispatcher` from your game and setting +`scaleThreshold` before any `ScaleCallbacks` component mounts: + +```dart +class MyGame extends FlameGame { + @override + Future onLoad() async { + final dispatcher = MultiDragScaleDispatcher()..scaleThreshold = 1.02; + registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + add(dispatcher); + } +} +``` + +A lower value makes the recognizer more sensitive (reacts to smaller pinch movements), while a +higher value requires a more deliberate gesture before scale events fire. + + ## Combining with DragCallbacks A component can use both `ScaleCallbacks` and `DragCallbacks` at the same time. When both mixins are 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 index 09de9077573..6360bd9ef8e 100644 --- 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 @@ -37,6 +37,12 @@ class MultiDragScaleDispatcher extends Dispatcher int _scaleCount = 0; MultiDragScaleGestureRecognizer? _recognizer; + /// The minimum scale factor change required before a scale gesture is + /// recognized. Must be greater than 1.0. The default value is 1.05, meaning + /// the fingers must spread or pinch by at least 5% before scale events start + /// firing. Set this before the first [ScaleCallbacks] component mounts. + double scaleThreshold = 1.05; + @visibleForTesting bool get hasDrag => _dragCount > 0; @@ -350,7 +356,7 @@ class MultiDragScaleDispatcher extends Dispatcher @override void onMount() { game.gestureDetectors.register( - MultiDragScaleGestureRecognizer.new, + () => MultiDragScaleGestureRecognizer(scaleThreshold: scaleThreshold), (MultiDragScaleGestureRecognizer instance) { _recognizer = instance; instance.hasDrag = _dragCount > 0; From 966e5f448693e7d2bc57602c9051ccb5f6ddd4ca Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 18 May 2026 18:01:16 +0200 Subject: [PATCH 83/87] fix: Make rotations final and remove configurable scaleThreshold for now Fix analyzer hints caused by var rotations no longer being reassigned after removing the sublist(1) call. Remove the configurable scaleThreshold field from MultiDragScaleDispatcher to be introduced in a separate PR. --- doc/flame/inputs/scale_events.md | 17 ----------------- .../scale_drag_dispatcher.dart | 8 +------- .../component_mixins/scale_callbacks_test.dart | 2 +- .../scale_drag_callbacks_test.dart | 2 +- 4 files changed, 3 insertions(+), 26 deletions(-) diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 3fbd0704048..60bab215602 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -116,23 +116,6 @@ threshold must be crossed first. By default, the fingers must spread or pinch by scale factor of 1.05) before `onScaleStart` is called. This prevents accidental scale gestures when the user simply places two fingers without intending to scale. -The threshold can be changed by accessing the `MultiDragScaleDispatcher` from your game and setting -`scaleThreshold` before any `ScaleCallbacks` component mounts: - -```dart -class MyGame extends FlameGame { - @override - Future onLoad() async { - final dispatcher = MultiDragScaleDispatcher()..scaleThreshold = 1.02; - registerKey(const MultiDragScaleDispatcherKey(), dispatcher); - add(dispatcher); - } -} -``` - -A lower value makes the recognizer more sensitive (reacts to smaller pinch movements), while a -higher value requires a more deliberate gesture before scale events fire. - ## Combining with DragCallbacks 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 index 6360bd9ef8e..09de9077573 100644 --- 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 @@ -37,12 +37,6 @@ class MultiDragScaleDispatcher extends Dispatcher int _scaleCount = 0; MultiDragScaleGestureRecognizer? _recognizer; - /// The minimum scale factor change required before a scale gesture is - /// recognized. Must be greater than 1.0. The default value is 1.05, meaning - /// the fingers must spread or pinch by at least 5% before scale events start - /// firing. Set this before the first [ScaleCallbacks] component mounts. - double scaleThreshold = 1.05; - @visibleForTesting bool get hasDrag => _dragCount > 0; @@ -356,7 +350,7 @@ class MultiDragScaleDispatcher extends Dispatcher @override void onMount() { game.gestureDetectors.register( - () => MultiDragScaleGestureRecognizer(scaleThreshold: scaleThreshold), + MultiDragScaleGestureRecognizer.new, (MultiDragScaleGestureRecognizer instance) { _recognizer = instance; instance.hasDrag = _dragCount > 0; 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 b90245a310f..4dbba6e72c8 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -404,7 +404,7 @@ void main() { height: resolution.y, ), ); - var rotations = []; + final rotations = []; game.camera.viewfinder.zoom = 3; 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 7c09b48cefe..bf07d6a1adb 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 @@ -527,7 +527,7 @@ void main() { height: resolution.y, ), ); - var rotations = []; + final rotations = []; game.camera.viewfinder.zoom = 3; From c170e4f43fe8acc5bc5d9ee08c2a4124e000d90d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 18 May 2026 18:01:54 +0200 Subject: [PATCH 84/87] feat: Add configurable scaleThreshold to MultiDragScaleDispatcher Allow users to tune the scale gesture sensitivity by setting scaleThreshold on the dispatcher before any ScaleCallbacks components mount. --- doc/flame/inputs/scale_events.md | 17 +++++++++++++++++ .../scale_drag_dispatcher.dart | 8 +++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 60bab215602..3fbd0704048 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -116,6 +116,23 @@ threshold must be crossed first. By default, the fingers must spread or pinch by scale factor of 1.05) before `onScaleStart` is called. This prevents accidental scale gestures when the user simply places two fingers without intending to scale. +The threshold can be changed by accessing the `MultiDragScaleDispatcher` from your game and setting +`scaleThreshold` before any `ScaleCallbacks` component mounts: + +```dart +class MyGame extends FlameGame { + @override + Future onLoad() async { + final dispatcher = MultiDragScaleDispatcher()..scaleThreshold = 1.02; + registerKey(const MultiDragScaleDispatcherKey(), dispatcher); + add(dispatcher); + } +} +``` + +A lower value makes the recognizer more sensitive (reacts to smaller pinch movements), while a +higher value requires a more deliberate gesture before scale events fire. + ## Combining with DragCallbacks 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 index 09de9077573..6360bd9ef8e 100644 --- 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 @@ -37,6 +37,12 @@ class MultiDragScaleDispatcher extends Dispatcher int _scaleCount = 0; MultiDragScaleGestureRecognizer? _recognizer; + /// The minimum scale factor change required before a scale gesture is + /// recognized. Must be greater than 1.0. The default value is 1.05, meaning + /// the fingers must spread or pinch by at least 5% before scale events start + /// firing. Set this before the first [ScaleCallbacks] component mounts. + double scaleThreshold = 1.05; + @visibleForTesting bool get hasDrag => _dragCount > 0; @@ -350,7 +356,7 @@ class MultiDragScaleDispatcher extends Dispatcher @override void onMount() { game.gestureDetectors.register( - MultiDragScaleGestureRecognizer.new, + () => MultiDragScaleGestureRecognizer(scaleThreshold: scaleThreshold), (MultiDragScaleGestureRecognizer instance) { _recognizer = instance; instance.hasDrag = _dragCount > 0; From 20dc8724d4518cfa27b5f4429b9363aef65ca6f3 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 18 May 2026 18:13:18 +0200 Subject: [PATCH 85/87] docs: Rename InteractiveRect to InteractiveRectangle in examples --- doc/flame/inputs/drag_events.md | 2 +- doc/flame/inputs/scale_events.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/flame/inputs/drag_events.md b/doc/flame/inputs/drag_events.md index 0378318f6e4..e0887366ac3 100644 --- a/doc/flame/inputs/drag_events.md +++ b/doc/flame/inputs/drag_events.md @@ -129,7 +129,7 @@ scale events. This is useful for components that should be draggable with one fi pinch-to-zoom or rotatable with two fingers. ```dart -class InteractiveRect extends RectangleComponent +class InteractiveRectangle extends RectangleComponent with ScaleCallbacks, DragCallbacks { double _initialAngle = 0; diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 3fbd0704048..aa841bcf1cc 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -142,7 +142,7 @@ drag events. This is useful for components that should be draggable with one fin pinch-to-zoom or rotatable with two fingers. ```dart -class InteractiveRect extends RectangleComponent +class InteractiveRectangle extends RectangleComponent with ScaleCallbacks, DragCallbacks { double _initialAngle = 0; From fc0c129937673e329d869f7097ae49c8eccfac26 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 20 May 2026 10:47:01 +0200 Subject: [PATCH 86/87] refactor: Address review feedback on recognizer and dispatcher - Clarify class docstring: scale fires only after threshold, not immediately - Save _scale.currentFocalPoint! to local variable in _updateScale - Add message to the addAllowedPointer assert - Note that cancel does not need to reset initial scale fields - Add comment explaining _dragCount/_scaleCount reference-count purpose - Add comment explaining the double-call pattern in addDispatcher - Add comment that spans are always non-negative - Extract repeated 80x60 game factory into makeFixedResolutionGame helper - Remove redundant components.dart imports in test files --- .../scale_drag_dispatcher.dart | 8 +++ .../events/multi_drag_scale_recognizer.dart | 25 ++++--- .../component_mixins/input_test_helper.dart | 6 ++ .../scale_callbacks_test.dart | 17 +---- .../scale_drag_callbacks_test.dart | 65 +++---------------- 5 files changed, 39 insertions(+), 82 deletions(-) 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 index 6360bd9ef8e..ffc826dab4a 100644 --- 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 @@ -33,6 +33,9 @@ class MultiDragScaleDispatcher extends Dispatcher /// The record of all components currently being touched. final Set> _records = {}; + // Reference counts rather than booleans so that enableDrag/enableScale can + // be called before onMount (when _recognizer is null). onMount uses these + // counts to initialise the recognizer flags. int _dragCount = 0; int _scaleCount = 0; MultiDragScaleGestureRecognizer? _recognizer; @@ -83,6 +86,11 @@ class MultiDragScaleDispatcher extends Dispatcher /// Ensures a [MultiDragScaleDispatcher] is registered on the game that owns /// [component], then enables drag and/or scale as requested. + /// + /// For a component that mixes both [DragCallbacks] and [ScaleCallbacks], + /// this method is called twice from their separate [onMount] chains, once + /// with [hasDrag]=true and once with [hasScale]=true. The second call finds + /// the existing dispatcher and simply enables the remaining flag. static void addDispatcher( Component component, { required bool hasDrag, diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index b2cc47c675c..9ee68394ebf 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -5,9 +5,10 @@ import 'package:flutter/gestures.dart'; /// and scale gestures simultaneously. /// /// This recognizer tracks each pointer independently (like Flutter's -/// ImmediateMultiDragGestureRecognizer) while also tracking the overall scale -/// gesture (like Flutter's ScaleGestureRecognizer). Each pointer can drag -/// independently, and when 2+ pointers are down, scale callbacks also fire. +/// [ImmediateMultiDragGestureRecognizer]) while also tracking the overall +/// scale gesture (like Flutter's [ScaleGestureRecognizer]). Each pointer +/// fires its own drag callbacks independently. When 2+ pointers are down +/// and movement exceeds [scaleThreshold], scale callbacks also fire. /// /// Use [hasDrag] and [hasScale] to enable only the features needed. Both /// default to false; the dispatcher sets them via enableDrag/enableScale. @@ -60,7 +61,10 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { if (!hasDrag && !hasScale) { return; } - assert(!_drag.pointers.containsKey(event.pointer)); + assert( + !_drag.pointers.containsKey(event.pointer), + 'Pointer ${event.pointer} is already tracked by this recognizer.', + ); final state = _DragPointerState(recognizer: this, event: event); _drag.pointers[event.pointer] = state; GestureBinding.instance.pointerRouter.addRoute(event.pointer, _handleEvent); @@ -116,6 +120,8 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _updateScaleFields(); _updateLines(); _endScaleIfNeeded(); + // No need to reset initialSpan/initialLine here: addAllowedPointer + // re-initialises them whenever a new two-finger gesture begins. } } else if (event is! PointerDownEvent) { assert(false); @@ -126,6 +132,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _scale.lastTransform = event.transform; _updateScaleFields(); _updateLines(); + final focalPoint = _scale.currentFocalPoint!; if (_drag.count >= 2 && !_scale.active && _checkScaleGestureThreshold()) { _scale.active = true; @@ -136,7 +143,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { invokeCallback('onScaleStart', () { onScaleStart!( ScaleStartDetails( - focalPoint: _scale.currentFocalPoint!, + focalPoint: focalPoint, localFocalPoint: _scale.localFocalPoint, pointerCount: pointerCount, sourceTimeStamp: _scale.initialEventTimestamp, @@ -147,10 +154,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { } if (_scale.active && _drag.count >= 2) { - _scale.velocityTracker?.addPosition( - event.timeStamp, - _scale.currentFocalPoint!, - ); + _scale.velocityTracker?.addPosition(event.timeStamp, focalPoint); if (onScaleUpdate != null) { invokeCallback('onScaleUpdate', () { @@ -159,7 +163,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { scale: _scale.scaleFactor, horizontalScale: _scale.horizontalScaleFactor, verticalScale: _scale.verticalScaleFactor, - focalPoint: _scale.currentFocalPoint!, + focalPoint: focalPoint, localFocalPoint: _scale.localFocalPoint, rotation: _computeRotationFactor(), pointerCount: pointerCount, @@ -420,6 +424,7 @@ class _ScaleState { VelocityTracker? velocityTracker; Duration? initialEventTimestamp; + // Spans are mean distances from the focal point, so they are always >= 0. double get scaleFactor => initialSpan > 0.0 ? currentSpan / initialSpan : 1.0; double get horizontalScaleFactor => initialHorizontalSpan > 0.0 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 395dd463247..79af9556f77 100644 --- a/packages/flame/test/events/component_mixins/input_test_helper.dart +++ b/packages/flame/test/events/component_mixins/input_test_helper.dart @@ -4,6 +4,12 @@ import 'package:flame/game.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_test/flutter_test.dart'; +/// Creates a [FlameGame] with a fixed 80x60 resolution camera, matching the +/// canvas size used across the scale/drag event tests. +FlameGame makeFixedResolutionGame() => FlameGame( + camera: CameraComponent.withFixedResolution(width: 80, height: 60), +); + mixin DragCounter on DragCallbacks { int dragStartEvent = 0; int dragUpdateEvent = 0; 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 4dbba6e72c8..19cb593734c 100644 --- a/packages/flame/test/events/component_mixins/scale_callbacks_test.dart +++ b/packages/flame/test/events/component_mixins/scale_callbacks_test.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:flame/components.dart'; import 'package:flame/events.dart' hide PointerMoveEvent; import 'package:flame/game.dart'; import 'package:flame_test/flame_test.dart'; @@ -354,13 +353,7 @@ void main() { 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 game = makeFixedResolutionGame(); final scales = []; game.camera.viewfinder.zoom = 3; @@ -397,13 +390,7 @@ void main() { testWidgets( 'scale event rotation respects camera & zoom', (tester) async { - final resolution = Vector2(80, 60); - final game = FlameGame( - camera: CameraComponent.withFixedResolution( - width: resolution.x, - height: resolution.y, - ), - ); + final game = makeFixedResolutionGame(); final rotations = []; game.camera.viewfinder.zoom = 3; 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 bf07d6a1adb..0058860593e 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 @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/game.dart'; import 'package:flame_test/flame_test.dart'; @@ -477,13 +476,7 @@ void main() { 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 game = makeFixedResolutionGame(); final scales = []; game.camera.viewfinder.zoom = 3; @@ -520,13 +513,7 @@ void main() { testWidgets( 'scale event rotation respects camera & zoom', (tester) async { - final resolution = Vector2(80, 60); - final game = FlameGame( - camera: CameraComponent.withFixedResolution( - width: resolution.x, - height: resolution.y, - ), - ); + final game = makeFixedResolutionGame(); final rotations = []; game.camera.viewfinder.zoom = 3; @@ -570,13 +557,7 @@ void main() { (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, - ), - ); + final game = makeFixedResolutionGame(); game.camera.viewfinder.zoom = 2; @@ -607,13 +588,7 @@ void main() { (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, - ), - ); + final game = makeFixedResolutionGame(); game.camera.viewfinder.zoom = 1 / 2; @@ -668,13 +643,7 @@ void main() { 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 game = makeFixedResolutionGame(); final component = ScaleDragWithCallbacksComponent( position: Vector2.all(-5), @@ -713,13 +682,7 @@ void main() { '''adding drag component after scale component upgrade dispatcher to multiDragMultiDragScaleDispatcher''', (tester) async { - final resolution = Vector2(80, 60); - final game = FlameGame( - camera: CameraComponent.withFixedResolution( - width: resolution.x, - height: resolution.y, - ), - ); + final game = makeFixedResolutionGame(); final scaleComponent = ScaleWithCallbacksComponent(); await game.world.add(scaleComponent); @@ -739,13 +702,7 @@ void main() { '''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 game = makeFixedResolutionGame(); final dragComponent = DragWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), @@ -777,13 +734,7 @@ void main() { '''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 game = makeFixedResolutionGame(); final scaleComponent = ScaleWithCallbacksComponent( position: Vector2.all(-5), size: Vector2.all(10), From 16aa48ad0f1d26c7ff984eb7c4906b23dd7e2bc1 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 22 May 2026 11:27:17 +0200 Subject: [PATCH 87/87] fix: Address remaining PR review feedback on recognizer - Fix globalPosition in _accepted to use initialPosition (not initialPosition + _pendingDelta) so the drag segment is correct - Hoist duplicate pointer-baseline code in addAllowedPointer and add comment explaining why 3+ pointers do not reset the baseline - Add focal-point pan slop check to _checkScaleGestureThreshold so pure two-finger pan triggers scale events (matching Flutter native behavior) - Fix British spellings: initialise -> initialize --- .../scale_drag_dispatcher.dart | 2 +- .../events/multi_drag_scale_recognizer.dart | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) 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 index ffc826dab4a..df5a3c47bc8 100644 --- 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 @@ -35,7 +35,7 @@ class MultiDragScaleDispatcher extends Dispatcher // Reference counts rather than booleans so that enableDrag/enableScale can // be called before onMount (when _recognizer is null). onMount uses these - // counts to initialise the recognizer flags. + // counts to initialize the recognizer flags. int _dragCount = 0; int _scaleCount = 0; MultiDragScaleGestureRecognizer? _recognizer; diff --git a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart index 9ee68394ebf..ffaeafd486d 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -74,20 +74,17 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { ); if (hasScale) { - if (_drag.count == 1) { + if (_drag.count <= 2) { + // Re-baseline focal point and spans on the first two pointer-down + // events. For count==1 the span is zero; for count==2 the 2-finger + // midpoint differs from the single-finger initial position, so we + // reset both. For 3+ pointers the gesture is already in progress; + // leave the baseline unchanged. _updateScaleFields(); _scale.initialFocalPoint = _scale.currentFocalPoint; _scale.initialSpan = _scale.currentSpan; _scale.initialHorizontalSpan = _scale.currentHorizontalSpan; _scale.initialVerticalSpan = _scale.currentVerticalSpan; - } else if (_drag.count == 2) { - // Re-initialize span with actual 2-finger distance so the threshold - // check measures movement from this baseline, not from the zero span - // of the single-pointer state. - _updateScaleFields(); - _scale.initialSpan = _scale.currentSpan; - _scale.initialHorizontalSpan = _scale.currentHorizontalSpan; - _scale.initialVerticalSpan = _scale.currentVerticalSpan; } } } @@ -121,7 +118,7 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { _updateLines(); _endScaleIfNeeded(); // No need to reset initialSpan/initialLine here: addAllowedPointer - // re-initialises them whenever a new two-finger gesture begins. + // re-initializes them whenever a new two-finger gesture begins. } } else if (event is! PointerDownEvent) { assert(false); @@ -230,12 +227,15 @@ class MultiDragScaleGestureRecognizer extends GestureRecognizer { return false; } + final kind = _drag.pointers.values.first.kind; final spanDelta = (_scale.currentSpan - _scale.initialSpan).abs(); final scaleFactor = _scale.scaleFactor; - final kind = _drag.pointers.values.first.kind; + final focalDelta = + (_scale.currentFocalPoint! - _scale.initialFocalPoint!).distance; if (spanDelta > computeScaleSlop(kind) || - math.max(scaleFactor, 1.0 / scaleFactor) > scaleThreshold) { + math.max(scaleFactor, 1.0 / scaleFactor) > scaleThreshold || + focalDelta > computePanSlop(kind, gestureSettings)) { for (final state in _drag.pointers.values) { if (!state._resolved) { state._arenaEntry?.resolve(GestureDisposition.accepted); @@ -531,7 +531,7 @@ class _DragPointerState { if (_drag != null && !recognizer.hasScale) { _drag!.update( DragUpdateDetails( - globalPosition: initialPosition + _pendingDelta, + globalPosition: initialPosition, delta: _pendingDelta, ), );