From 27c562cb86b4bf978b1aa98042af0454b4dd0201 Mon Sep 17 00:00:00 2001 From: Luan Nico Date: Fri, 7 Aug 2026 00:18:25 -0400 Subject: [PATCH] refactor!: Migrate Force Press events to new API --- doc/flame/inputs/gesture_input.md | 6 - doc/flame/inputs/tap_events.md | 49 +++ doc/flame/migration.md | 41 ++ packages/flame/lib/events.dart | 7 +- .../callbacks/force_press_callbacks.dart | 49 +++ .../dispatchers/force_press_dispatcher.dart | 119 ++++++ .../events/messages/force_press_event.dart | 39 ++ packages/flame/lib/src/game/flame_game.dart | 2 +- .../game_widget/gesture_detector_builder.dart | 11 - .../flame/lib/src/gestures/detectors.dart | 23 -- packages/flame/lib/src/gestures/events.dart | 9 - .../force_press_callbacks_test.dart | 364 ++++++++++++++++++ .../flame/test/gestures/detectors_test.dart | 165 -------- packages/flame_test/lib/flame_test.dart | 1 + .../lib/src/mock_force_press_events.dart | 19 + 15 files changed, 687 insertions(+), 217 deletions(-) create mode 100644 packages/flame/lib/src/events/callbacks/force_press_callbacks.dart create mode 100644 packages/flame/lib/src/events/dispatchers/force_press_dispatcher.dart create mode 100644 packages/flame/lib/src/events/messages/force_press_event.dart create mode 100644 packages/flame/test/events/component_mixins/force_press_callbacks_test.dart create mode 100644 packages/flame_test/lib/src/mock_force_press_events.dart diff --git a/doc/flame/inputs/gesture_input.md b/doc/flame/inputs/gesture_input.md index fd3db730e0e..575d38ce50f 100644 --- a/doc/flame/inputs/gesture_input.md +++ b/doc/flame/inputs/gesture_input.md @@ -65,12 +65,6 @@ Detectors will be deprecated in the future. Prefer `Callbacks` instead. - onHorizontalDragEnd - onHorizontalDragCancel -- ForcePressDetector - - onForcePressStart - - onForcePressPeak - - onForcePressUpdate - - onForcePressEnd - - PanDetector - onPanDown - onPanStart diff --git a/doc/flame/inputs/tap_events.md b/doc/flame/inputs/tap_events.md index 26691c45aed..8d6f5e401f2 100644 --- a/doc/flame/inputs/tap_events.md +++ b/doc/flame/inputs/tap_events.md @@ -250,6 +250,55 @@ class MyComponent extends PositionComponent with DoubleTapCallbacks { ``` +### ForcePressCallbacks + +The `ForcePressCallbacks` mixin gives a component access to force press gestures, i.e. touches that +report how hard the user is pressing. + +```{warning} +Force press requires a pressure-sensitive screen: Apple's 3D Touch, which +shipped on the iPhone 6s through the iPhone XS, or a small number of Android +devices. On every other device the gesture is never recognized and these +callbacks never fire. +``` + +All four callbacks receive the same `ForcePressEvent`, whose `pressure` is normalized to the +`[0, 1]` range across the pressure range the device reports: + +```dart +class MyComponent extends PositionComponent with ForcePressCallbacks { + MyComponent() : super(size: Vector2.all(100)); + + @override + void onForcePressStart(ForcePressEvent event) { + super.onForcePressStart(event); + // The press crossed the threshold at which the gesture is recognized. + } + + @override + void onForcePressPeak(ForcePressEvent event) { + // The press crossed the "peak" pressure threshold. + } + + @override + void onForcePressUpdate(ForcePressEvent event) { + scale = Vector2.all(1 + event.pressure); + } + + @override + void onForcePressEnd(ForcePressEvent event) { + super.onForcePressEnd(event); + scale = Vector2.all(1); + } +} +``` + +Only the topmost component under the point of contact receives `onForcePressStart`; set +`event.continuePropagation` to true to let it through to the components below. Once a component has +accepted the gesture it keeps receiving the peak, update and end events even if the pointer moves +outside its bounds. The `isForcePressed` getter is true while a gesture is active. + + ## Migration If you have an existing game that uses `Tappable`/`Draggable` mixins, then this section will diff --git a/doc/flame/migration.md b/doc/flame/migration.md index d145b5fe0e2..655656b416f 100644 --- a/doc/flame/migration.md +++ b/doc/flame/migration.md @@ -7,6 +7,47 @@ major versions of Flame, together with the steps required to migrate your code. ## Migrating from v1.38.0 to v2.0.0 +### `ForcePressDetector` replaced by `ForcePressCallbacks` + +Force press was the last gesture that only existed on the old game-level detector API. The +`ForcePressDetector` mixin and its `ForcePressInfo` event class have been removed, and replaced by +the `ForcePressCallbacks` component mixin and the `ForcePressEvent` class, in line with every other +gesture. + +The four callbacks keep their names, but they now receive a single `ForcePressEvent` instead of a +`ForcePressInfo`, and they are declared on a component rather than on the game: + +```dart +// Before +class MyGame extends FlameGame with ForcePressDetector { + @override + void onForcePressUpdate(ForcePressInfo info) { + final position = info.eventPosition.widget; + final pressure = info.pressure; + } +} + +// After +class MyComponent extends PositionComponent with ForcePressCallbacks { + @override + void onForcePressUpdate(ForcePressEvent event) { + final position = event.localPosition; + final pressure = event.pressure; + } +} +``` + +As with the other callback mixins, the event is delivered only to components under the point of +contact, `event.localPosition` is available in addition to `canvasPosition` and `devicePosition`, +and propagation past the topmost component is opt-in via `event.continuePropagation`. + +Note that force press requires a pressure-sensitive screen — Apple's 3D Touch, which shipped on the +iPhone 6s through the +[iPhone XS](https://support.apple.com/guide/iphone/aside/iph945ccc462/14.0/ios/14.0), or a small +number of Android devices. On any other device the gesture is never recognized, so these callbacks +never fire. This is unchanged from the old API. + + ### `onDragCancel` no longer delegates to `onDragEnd` `DragCallbacks.onDragCancel` used to convert the cancellation into an `onDragEnd` event by default, diff --git a/packages/flame/lib/events.dart b/packages/flame/lib/events.dart index 8ab167f20cb..21f7f5118fb 100644 --- a/packages/flame/lib/events.dart +++ b/packages/flame/lib/events.dart @@ -1,5 +1,7 @@ export 'src/events/callbacks/double_tap_callbacks.dart' show DoubleTapCallbacks; export 'src/events/callbacks/drag_callbacks.dart' show DragCallbacks; +export 'src/events/callbacks/force_press_callbacks.dart' + show ForcePressCallbacks; export 'src/events/callbacks/hover_callbacks.dart' show HoverCallbacks; export 'src/events/callbacks/long_press_callbacks.dart' show LongPressCallbacks; export 'src/events/callbacks/pointer_move_callbacks.dart' @@ -14,6 +16,8 @@ export 'src/events/callbacks/tertiary_tap_callbacks.dart' export 'src/events/dispatchers/dispatcher.dart' show Dispatcher; export 'src/events/dispatchers/double_tap_dispatcher.dart' show DoubleTapDispatcher, DoubleTapDispatcherKey; +export 'src/events/dispatchers/force_press_dispatcher.dart' + show ForcePressDispatcher, ForcePressDispatcherKey; export 'src/events/dispatchers/long_press_dispatcher.dart' show LongPressDispatcher, LongPressDispatcherKey; export 'src/events/dispatchers/multi_drag_scale_dispatcher.dart' @@ -45,6 +49,7 @@ 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/event.dart' show Event; +export 'src/events/messages/force_press_event.dart' show ForcePressEvent; export 'src/events/messages/location_context_event.dart' show LocationContextEvent; export 'src/events/messages/long_press_cancel_event.dart' @@ -81,7 +86,6 @@ export 'src/game/mixins/keyboard.dart' export 'src/gestures/detectors.dart' show DoubleTapDetector, - ForcePressDetector, HorizontalDragDetector, LongPressDetector, MouseMovementDetector, @@ -97,7 +101,6 @@ export 'src/gestures/events.dart' DragEndInfo, DragStartInfo, DragUpdateInfo, - ForcePressInfo, LongPressEndInfo, LongPressMoveUpdateInfo, LongPressStartInfo, diff --git a/packages/flame/lib/src/events/callbacks/force_press_callbacks.dart b/packages/flame/lib/src/events/callbacks/force_press_callbacks.dart new file mode 100644 index 00000000000..b4a0a49f542 --- /dev/null +++ b/packages/flame/lib/src/events/callbacks/force_press_callbacks.dart @@ -0,0 +1,49 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flutter/foundation.dart'; + +/// This mixin can be added to a [Component] allowing it to receive force press +/// events, i.e. touches that report how hard the user is pressing. +/// +/// In addition to adding this mixin, the component must also implement the +/// [containsLocalPoint] method -- only a gesture that starts on top of the +/// component will be delivered to it. +/// +/// The following callbacks are available: +/// - [onForcePressStart]: the press crossed the pressure threshold at which +/// the gesture is recognized. +/// - [onForcePressPeak]: the press crossed the "peak" pressure threshold. +/// - [onForcePressUpdate]: the pressure changed during an active force press. +/// - [onForcePressEnd]: the pointer was lifted. +/// +/// Note that force press requires a pressure-sensitive screen; see +/// [ForcePressEvent] for the details of which devices support it. +/// +/// This callback uses [ForcePressDispatcher] to route events. +mixin ForcePressCallbacks on Component { + bool _isForcePressed = false; + + /// Returns true while a force press gesture is active on this component. + bool get isForcePressed => _isForcePressed; + + @mustCallSuper + void onForcePressStart(ForcePressEvent event) { + _isForcePressed = true; + } + + void onForcePressPeak(ForcePressEvent event) {} + + void onForcePressUpdate(ForcePressEvent event) {} + + @mustCallSuper + void onForcePressEnd(ForcePressEvent event) { + _isForcePressed = false; + } + + @override + @mustCallSuper + void onMount() { + super.onMount(); + ForcePressDispatcher.addDispatcher(this); + } +} diff --git a/packages/flame/lib/src/events/dispatchers/force_press_dispatcher.dart b/packages/flame/lib/src/events/dispatchers/force_press_dispatcher.dart new file mode 100644 index 00000000000..30a2728c195 --- /dev/null +++ b/packages/flame/lib/src/events/dispatchers/force_press_dispatcher.dart @@ -0,0 +1,119 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/gestures.dart'; +import 'package:meta/meta.dart'; + +/// **ForcePressDispatcher** facilitates dispatching of force press events to +/// the [ForcePressCallbacks] components in the component tree. It will be +/// attached to the [FlameGame] instance automatically whenever any +/// [ForcePressCallbacks] components are mounted into the component tree. +/// +/// Flutter's [ForcePressGestureRecognizer] tracks a single pointer at a time, +/// so this dispatcher only needs to remember the set of components that +/// accepted the current gesture, rather than keying them by pointer id. +class ForcePressDispatcher extends Dispatcher { + /// The components that received the current gesture's start event, and which + /// will therefore receive its peak, update and end events. + final Set _components = {}; + + @mustCallSuper + void onForcePressStart(ForcePressEvent event) { + event.deliverAtPoint( + rootComponent: game, + eventHandler: (ForcePressCallbacks component) { + _components.add(component..onForcePressStart(event)); + }, + ); + } + + @mustCallSuper + void onForcePressPeak(ForcePressEvent event) { + _forEachActiveComponent((component) => component.onForcePressPeak(event)); + } + + @mustCallSuper + void onForcePressUpdate(ForcePressEvent event) { + _forEachActiveComponent((component) => component.onForcePressUpdate(event)); + } + + @mustCallSuper + void onForcePressEnd(ForcePressEvent event) { + _forEachActiveComponent((component) => component.onForcePressEnd(event)); + _components.clear(); + } + + /// Delivers to every component that accepted the gesture and is still + /// mounted, dropping the ones that were removed mid-gesture. + void _forEachActiveComponent(void Function(ForcePressCallbacks) handler) { + _components.removeWhere((component) => !component.isMounted); + for (final component in _components) { + handler(component); + } + } + + //#region Gesture recognizer handlers + + @internal + void handleForcePressStart(ForcePressDetails details) { + onForcePressStart(ForcePressEvent(game, details)); + } + + @internal + void handleForcePressPeak(ForcePressDetails details) { + onForcePressPeak(ForcePressEvent(game, details)); + } + + @internal + void handleForcePressUpdate(ForcePressDetails details) { + onForcePressUpdate(ForcePressEvent(game, details)); + } + + @internal + void handleForcePressEnd(ForcePressDetails details) { + onForcePressEnd(ForcePressEvent(game, details)); + } + + //#endregion + + static void addDispatcher(Component component) { + Dispatcher.addDispatcher( + component, + const ForcePressDispatcherKey(), + ForcePressDispatcher.new, + ); + } + + @override + void onMount() { + game.gestureDetectors.register( + ForcePressGestureRecognizer.new, + (ForcePressGestureRecognizer instance) { + instance + ..onStart = handleForcePressStart + ..onPeak = handleForcePressPeak + ..onUpdate = handleForcePressUpdate + ..onEnd = handleForcePressEnd; + }, + ); + super.onMount(); + } + + @override + void onRemove() { + game.gestureDetectors.unregister(); + Dispatcher.removeDispatcher(game, const ForcePressDispatcherKey()); + } +} + +/// Unique key for the [ForcePressDispatcher] so the game can identify it. +class ForcePressDispatcherKey implements ComponentKey { + const ForcePressDispatcherKey(); + + @override + int get hashCode => 'ForcePressDispatcherKey'.hashCode; + + @override + bool operator ==(Object other) => + other is ForcePressDispatcherKey && other.hashCode == hashCode; +} diff --git a/packages/flame/lib/src/events/messages/force_press_event.dart b/packages/flame/lib/src/events/messages/force_press_event.dart new file mode 100644 index 00000000000..1314b69637e --- /dev/null +++ b/packages/flame/lib/src/events/messages/force_press_event.dart @@ -0,0 +1,39 @@ +import 'package:flame/events.dart'; +import 'package:flame/extensions.dart'; +import 'package:flutter/gestures.dart'; + +/// The event propagated through the Flame engine during a force press gesture, +/// i.e. a touch that also reports how hard the user is pressing. +/// +/// The same event class is used for all four phases of the gesture (start, +/// peak, update and end) because Flutter describes every one of them with a +/// single [ForcePressDetails] object. +/// +/// This is a [PositionEvent], where the position is the point of contact. +/// +/// Note that force press requires a pressure-sensitive screen: Apple's 3D +/// Touch, which shipped on the iPhone 6s through the iPhone XS, or a small +/// number of Android devices. On any other device the gesture is never +/// recognized and these events are never delivered. +class ForcePressEvent extends PositionEvent { + ForcePressEvent(super.game, ForcePressDetails details) + : pressure = details.pressure, + super( + raw: details, + devicePosition: details.globalPosition.toVector2(), + ); + + /// How hard the user is pressing, normalized to the `[0, 1]` range across + /// the pressure range that the device reports. + /// + /// The gesture is only recognized once this value crosses the recognizer's + /// `startPressure` (`0.4` by default), and `onForcePressPeak` fires when it + /// crosses `peakPressure` (`0.85` by default). + final double pressure; + + @override + String toString() => + 'ForcePressEvent(canvasPosition: $canvasPosition, ' + 'devicePosition: $devicePosition, ' + 'pressure: $pressure)'; +} diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index dd5eecba7d5..cf941d63bc0 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -255,7 +255,6 @@ class FlameGame extends ComponentTreeRoot this is LongPressDetector || this is VerticalDragDetector || this is HorizontalDragDetector || - this is ForcePressDetector || this is PanDetector || this is ScaleDetector || this is MultiTapListener || @@ -266,6 +265,7 @@ class FlameGame extends ComponentTreeRoot if (component is TapCallbacks || component is DragCallbacks || component is DoubleTapCallbacks || + component is ForcePressCallbacks || component is ScaleCallbacks || component is SecondaryTapCallbacks) { return true; diff --git a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart index 4699abe1337..df94f4cdc26 100644 --- a/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart +++ b/packages/flame/lib/src/game/game_widget/gesture_detector_builder.dart @@ -121,17 +121,6 @@ class GestureDetectorBuilder { }, ); } - if (game is ForcePressDetector) { - register( - ForcePressGestureRecognizer.new, - (ForcePressGestureRecognizer instance) { - instance.onStart = game.handleForcePressStart; - instance.onPeak = game.handleForcePressPeak; - instance.onUpdate = game.handleForcePressUpdate; - instance.onEnd = game.handleForcePressEnd; - }, - ); - } if (game is PanDetector) { register( PanGestureRecognizer.new, diff --git a/packages/flame/lib/src/gestures/detectors.dart b/packages/flame/lib/src/gestures/detectors.dart index 352c60d93bf..3fd8eae9b4b 100644 --- a/packages/flame/lib/src/gestures/detectors.dart +++ b/packages/flame/lib/src/gestures/detectors.dart @@ -129,29 +129,6 @@ mixin HorizontalDragDetector on Game { } } -mixin ForcePressDetector on Game { - void onForcePressStart(ForcePressInfo info) {} - void onForcePressPeak(ForcePressInfo info) {} - void onForcePressUpdate(ForcePressInfo info) {} - void onForcePressEnd(ForcePressInfo info) {} - - void handleForcePressStart(ForcePressDetails details) { - onForcePressStart(ForcePressInfo.fromDetails(this, details)); - } - - void handleForcePressPeak(ForcePressDetails details) { - onForcePressPeak(ForcePressInfo.fromDetails(this, details)); - } - - void handleForcePressUpdate(ForcePressDetails details) { - onForcePressUpdate(ForcePressInfo.fromDetails(this, details)); - } - - void handleForcePressEnd(ForcePressDetails details) { - onForcePressEnd(ForcePressInfo.fromDetails(this, details)); - } -} - mixin PanDetector on Game { void onPanDown(DragDownInfo info) {} void onPanStart(DragStartInfo info) {} diff --git a/packages/flame/lib/src/gestures/events.dart b/packages/flame/lib/src/gestures/events.dart index f7b6c5ed66f..c59576c4119 100644 --- a/packages/flame/lib/src/gestures/events.dart +++ b/packages/flame/lib/src/gestures/events.dart @@ -98,15 +98,6 @@ class LongPressMoveUpdateInfo extends PositionInfo { ) : super(game, raw.globalPosition, raw); } -class ForcePressInfo extends PositionInfo { - late final double pressure = raw.pressure; - - ForcePressInfo.fromDetails( - Game game, - ForcePressDetails raw, - ) : super(game, raw.globalPosition, raw); -} - class PointerScrollInfo extends PositionInfo { late final EventDelta scrollDelta = EventDelta(raw.scrollDelta); diff --git a/packages/flame/test/events/component_mixins/force_press_callbacks_test.dart b/packages/flame/test/events/component_mixins/force_press_callbacks_test.dart new file mode 100644 index 00000000000..713dbc6cbd1 --- /dev/null +++ b/packages/flame/test/events/component_mixins/force_press_callbacks_test.dart @@ -0,0 +1,364 @@ +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'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ForcePressCallbacks', () { + testWithFlameGame( + 'adds the dispatcher to the game', + (game) async { + await game.ensureAdd(_ForcePressComponent()); + await game.ready(); + + _hasDispatcher(game); + }, + ); + + testWithFlameGame( + 'the dispatcher is only added once', + (game) async { + await game.ensureAdd(_ForcePressComponent()); + await game.ensureAdd(_ForcePressComponent()); + await game.ready(); + + _hasDispatcher(game); + }, + ); + + testWithFlameGame( + 'receives force press start on component', + (game) async { + final component = _ForcePressComponent( + position: Vector2.all(10), + size: Vector2.all(10), + ); + await game.ensureAdd(component); + + _forcePressStart(game, const Offset(15, 15), 0.5); + + expect(component.startCount, 1); + expect(component.isForcePressed, isTrue); + expect(component.lastPressure, 0.5); + }, + ); + + testWithFlameGame( + 'does not receive events that start outside its bounds', + (game) async { + final component = _ForcePressComponent( + position: Vector2.all(10), + size: Vector2.all(10), + ); + await game.ensureAdd(component); + + _forcePressStart(game, const Offset(5, 5), 0.5); + + expect(component.startCount, 0); + expect(component.isForcePressed, isFalse); + }, + ); + + testWithFlameGame( + 'full lifecycle: start, peak, update, end', + (game) async { + final component = _ForcePressComponent( + position: Vector2.all(10), + size: Vector2.all(10), + ); + await game.ensureAdd(component); + + _forcePressStart(game, const Offset(15, 15), 0.5); + _forcePressPeak(game, const Offset(15, 15), 0.9); + _forcePressUpdate(game, const Offset(15, 15), 0.95); + _forcePressEnd(game, const Offset(15, 15), 0.1); + + expect(component.startCount, 1); + expect(component.peakCount, 1); + expect(component.updateCount, 1); + expect(component.endCount, 1); + expect(component.isForcePressed, isFalse); + }, + ); + + testWithFlameGame( + 'peak, update and end follow the pointer outside the component', + (game) async { + final component = _ForcePressComponent( + position: Vector2.all(10), + size: Vector2.all(10), + ); + await game.ensureAdd(component); + + _forcePressStart(game, const Offset(15, 15), 0.5); + // The pointer moved off the component; the gesture still belongs to it. + _forcePressUpdate(game, const Offset(500, 500), 0.7); + _forcePressEnd(game, const Offset(500, 500), 0.1); + + expect(component.updateCount, 1); + expect(component.endCount, 1); + }, + ); + + testWithFlameGame( + 'a component removed mid-gesture stops receiving events', + (game) async { + final component = _ForcePressComponent( + position: Vector2.all(10), + size: Vector2.all(10), + ); + await game.ensureAdd(component); + + _forcePressStart(game, const Offset(15, 15), 0.5); + expect(component.startCount, 1); + + component.removeFromParent(); + await game.ready(); + + _forcePressUpdate(game, const Offset(15, 15), 0.7); + _forcePressEnd(game, const Offset(15, 15), 0.1); + + expect(component.updateCount, 0); + expect(component.endCount, 0); + }, + ); + + testWithFlameGame( + 'the event exposes positions and the raw details', + (game) async { + final component = _ForcePressComponent( + position: Vector2.all(10), + size: Vector2.all(10), + ); + await game.ensureAdd(component); + + _forcePressStart(game, const Offset(15, 15), 0.75); + + final event = component.lastEvent!; + expect(event.raw, isA()); + expect(event.pressure, 0.75); + expect(event.devicePosition, Vector2(15, 15)); + expect(event.canvasPosition, Vector2(15, 15)); + expect(event.localPosition, Vector2(5, 5)); + }, + ); + + testWithFlameGame( + 'only the topmost component receives the start by default', + (game) async { + final bottom = _ForcePressComponent(size: Vector2.all(100)); + final top = _ForcePressComponent(size: Vector2.all(100)); + await game.ensureAddAll([bottom, top]); + + _forcePressStart(game, const Offset(50, 50), 0.5); + + expect(top.startCount, 1); + expect(bottom.startCount, 0); + }, + ); + + testWithFlameGame( + 'continuePropagation lets the start reach components below', + (game) async { + final bottom = _ForcePressComponent(size: Vector2.all(100)); + final top = _ForcePressComponent( + size: Vector2.all(100), + continuePropagation: true, + ); + await game.ensureAddAll([bottom, top]); + + _forcePressStart(game, const Offset(50, 50), 0.5); + + expect(top.startCount, 1); + expect(bottom.startCount, 1); + }, + ); + + testWithGame( + 'FlameGame with ForcePressCallbacks receives events', + _ForcePressGame.new, + (game) async { + _hasDispatcher(game); + + _forcePressStart(game, const Offset(15, 15), 0.5); + + expect(game.startCount, 1); + expect(game.isForcePressed, isTrue); + }, + ); + + testWithFlameGame( + 'the dispatcher stays when the last component unmounts', + (game) async { + final component = _ForcePressComponent(size: Vector2.all(10)); + await game.ensureAdd(component); + _hasDispatcher(game); + + component.removeFromParent(); + await game.ready(); + + // The dispatcher is managed by the game, not by component unmounting. + _hasDispatcher(game); + }, + ); + + testWidgets( + 'a real force press gesture reaches the component', + (tester) async { + final component = _ForcePressComponent(size: Vector2.all(800)); + final game = FlameGame(children: [component]); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + expect(component.isMounted, isTrue); + + const position = Offset(50, 50); + final pointer = tester.nextPointer; + final gesture = await tester.createGesture(); + + await gesture.downWithCustomEvent( + position, + PointerDownEvent( + pointer: pointer, + position: position, + pressure: 0.0, + pressureMin: 0.0, + pressureMax: 6.0, + ), + ); + + // Below the start threshold: nothing is delivered yet. + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointer, + position: position, + pressure: 0.3, + pressureMin: 0.0, + pressureMax: 6.0, + ), + ); + expect(component.startCount, 0); + + // Crosses the start threshold. + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointer, + position: position, + pressure: 3.0, + pressureMin: 0.0, + pressureMax: 6.0, + ), + ); + expect(component.startCount, 1); + expect(component.isForcePressed, isTrue); + + // Crosses the peak threshold. + await gesture.updateWithCustomEvent( + PointerMoveEvent( + pointer: pointer, + position: position, + pressure: 5.5, + pressureMin: 0.0, + pressureMax: 6.0, + ), + ); + expect(component.peakCount, 1); + + await gesture.up(); + + expect(component.endCount, 1); + expect(component.isForcePressed, isFalse); + }, + ); + }); +} + +void _forcePressStart(FlameGame game, Offset position, double pressure) { + _dispatcher(game).handleForcePressStart(_details(position, pressure)); +} + +void _forcePressPeak(FlameGame game, Offset position, double pressure) { + _dispatcher(game).handleForcePressPeak(_details(position, pressure)); +} + +void _forcePressUpdate(FlameGame game, Offset position, double pressure) { + _dispatcher(game).handleForcePressUpdate(_details(position, pressure)); +} + +void _forcePressEnd(FlameGame game, Offset position, double pressure) { + _dispatcher(game).handleForcePressEnd(_details(position, pressure)); +} + +ForcePressDetails _details(Offset position, double pressure) { + return ForcePressDetails( + globalPosition: position, + localPosition: position, + pressure: pressure, + ); +} + +ForcePressDispatcher _dispatcher(FlameGame game) { + return game.firstChild()!; +} + +void _hasDispatcher(FlameGame game) { + expect(game.children.whereType(), hasLength(1)); +} + +class _ForcePressComponent extends PositionComponent with ForcePressCallbacks { + _ForcePressComponent({ + super.position, + super.size, + this.continuePropagation = false, + }); + + final bool continuePropagation; + + int startCount = 0; + int peakCount = 0; + int updateCount = 0; + int endCount = 0; + double? lastPressure; + ForcePressEvent? lastEvent; + + @override + void onForcePressStart(ForcePressEvent event) { + super.onForcePressStart(event); + event.continuePropagation = continuePropagation; + startCount++; + lastPressure = event.pressure; + lastEvent = event; + } + + @override + void onForcePressPeak(ForcePressEvent event) { + peakCount++; + lastPressure = event.pressure; + } + + @override + void onForcePressUpdate(ForcePressEvent event) { + updateCount++; + lastPressure = event.pressure; + } + + @override + void onForcePressEnd(ForcePressEvent event) { + super.onForcePressEnd(event); + endCount++; + lastPressure = event.pressure; + } +} + +class _ForcePressGame extends FlameGame with ForcePressCallbacks { + int startCount = 0; + + @override + void onForcePressStart(ForcePressEvent event) { + super.onForcePressStart(event); + startCount++; + } +} diff --git a/packages/flame/test/gestures/detectors_test.dart b/packages/flame/test/gestures/detectors_test.dart index 93808c5c048..45f188d8916 100644 --- a/packages/flame/test/gestures/detectors_test.dart +++ b/packages/flame/test/gestures/detectors_test.dart @@ -482,144 +482,6 @@ void main() { ); }); - group('ForcePressDetector', () { - final forcePressGame = FlameTester(_ForcePressDetectorGame.new); - - forcePressGame.testGameWidget( - 'can register forcePress', - verify: (game, tester) async { - const forcePressOffset = Offset(10, 10); - - final pointerValue = tester.nextPointer; - - final gesture = await tester.createGesture(); - await gesture.downWithCustomEvent( - forcePressOffset, - PointerDownEvent( - pointer: pointerValue, - position: forcePressOffset, - pressure: 0.0, - pressureMax: 6.0, - pressureMin: 0.0, - ), - ); - - await gesture.updateWithCustomEvent( - PointerMoveEvent( - pointer: pointerValue, - pressure: 0.3, - pressureMin: 0, - ), - ); - - expect(game.forcePressStart, equals(0)); - expect(game.forcePressPeaked, equals(0)); - expect(game.forcePressUpdate, equals(0)); - expect(game.forcePressEnded, equals(0)); - - await gesture.updateWithCustomEvent( - PointerMoveEvent( - pointer: pointerValue, - pressure: 0.5, - pressureMin: 0, - ), - ); - - expect(game.forcePressStart, equals(1)); - expect(game.forcePressPeaked, equals(0)); - expect(game.forcePressUpdate, equals(1)); - expect(game.forcePressEnded, equals(0)); - - await gesture.updateWithCustomEvent( - PointerMoveEvent( - pointer: pointerValue, - pressure: 0.9, - pressureMin: 0, - ), - ); - - expect(game.forcePressStart, equals(1)); - expect(game.forcePressPeaked, equals(1)); - expect(game.forcePressUpdate, equals(2)); - expect(game.forcePressEnded, equals(0)); - - await gesture.up(); - - expect(game.forcePressStart, equals(1)); - expect(game.forcePressPeaked, equals(1)); - expect(game.forcePressUpdate, equals(2)); - expect(game.forcePressEnded, equals(1)); - }, - ); - - testWithGame<_ForcePressDetectorGame>( - 'can be Force Press started', - _ForcePressDetectorGame.new, - (game) async { - await game.ready(); - - game.handleForcePressStart( - ForcePressDetails( - globalPosition: const Offset(10, 10), - pressure: 0.4, - ), - ); - - expect(game.forcePressStart, equals(1)); - }, - ); - - testWithGame<_ForcePressDetectorGame>( - 'can be Force Press Updated', - _ForcePressDetectorGame.new, - (game) async { - await game.ready(); - - game.handleForcePressUpdate( - ForcePressDetails( - globalPosition: const Offset(10, 10), - pressure: 0.7, - ), - ); - - expect(game.forcePressUpdate, equals(1)); - }, - ); - - testWithGame<_ForcePressDetectorGame>( - 'can be Force Press peaked', - _ForcePressDetectorGame.new, - (game) async { - await game.ready(); - - game.handleForcePressPeak( - ForcePressDetails( - globalPosition: const Offset(10, 10), - pressure: 0.9, - ), - ); - - expect(game.forcePressPeaked, equals(1)); - }, - ); - - testWithGame<_ForcePressDetectorGame>( - 'can be Force Press Ended', - _ForcePressDetectorGame.new, - (game) async { - await game.ready(); - - game.handleForcePressEnd( - ForcePressDetails( - globalPosition: const Offset(10, 10), - pressure: 0.2, - ), - ); - expect(game.forcePressEnded, equals(1)); - }, - ); - }); - group('PanDetector', () { final panGame = FlameTester(_PanDetectorGame.new); @@ -973,33 +835,6 @@ class _VerticalDragDetectorGame extends FlameGame with VerticalDragDetector { } } -class _ForcePressDetectorGame extends FlameGame with ForcePressDetector { - int forcePressStart = 0; - int forcePressPeaked = 0; - int forcePressUpdate = 0; - int forcePressEnded = 0; - - @override - void onForcePressStart(ForcePressInfo info) { - forcePressStart++; - } - - @override - void onForcePressEnd(ForcePressInfo info) { - forcePressEnded++; - } - - @override - void onForcePressUpdate(ForcePressInfo info) { - forcePressUpdate++; - } - - @override - void onForcePressPeak(ForcePressInfo info) { - forcePressPeaked++; - } -} - class _PanDetectorGame extends FlameGame with PanDetector { bool hasPanDown = false; bool hasPanCancel = false; diff --git a/packages/flame_test/lib/flame_test.dart b/packages/flame_test/lib/flame_test.dart index 5bba88df177..9ba75fcdaba 100644 --- a/packages/flame_test/lib/flame_test.dart +++ b/packages/flame_test/lib/flame_test.dart @@ -10,6 +10,7 @@ export 'src/expect_color.dart'; export 'src/expect_double.dart'; export 'src/fails_assert.dart'; export 'src/flame_test.dart'; +export 'src/mock_force_press_events.dart'; export 'src/mock_gesture_events.dart'; export 'src/mock_image.dart'; export 'src/mock_long_press_events.dart'; diff --git a/packages/flame_test/lib/src/mock_force_press_events.dart b/packages/flame_test/lib/src/mock_force_press_events.dart new file mode 100644 index 00000000000..c79fb80ee76 --- /dev/null +++ b/packages/flame_test/lib/src/mock_force_press_events.dart @@ -0,0 +1,19 @@ +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/gestures.dart'; + +ForcePressEvent createForcePressEvent({ + required Game game, + Offset? globalPosition, + Offset? localPosition, + double? pressure, +}) { + return ForcePressEvent( + game, + ForcePressDetails( + globalPosition: globalPosition ?? Offset.zero, + localPosition: localPosition ?? globalPosition ?? Offset.zero, + pressure: pressure ?? 0.5, + ), + ); +}