diff --git a/doc/flame/inputs/scale_events.md b/doc/flame/inputs/scale_events.md index 288df755917..df0b853096f 100644 --- a/doc/flame/inputs/scale_events.md +++ b/doc/flame/inputs/scale_events.md @@ -138,6 +138,27 @@ A lower value makes the recognizer more sensitive (reacts to smaller pinch movem higher value requires a more deliberate gesture before scale events fire. +## Trackpad gestures + +A trackpad pinch on desktop or the web reaches Flame as a scale gesture too, so `ScaleCallbacks` +works there without any extra code. Two-finger panning arrives the same way, with `scale` staying at +1 while the focal point travels, which you can read from `focalPointDelta`. + +A trackpad reports a gesture rather than individual fingers, which leads to two differences: + +- `pointerCount` is always 2 during a trackpad gesture. The platform does not say how many fingers + are actually down, so Flame reports the minimum it can be, as Flutter's own + `ScaleGestureRecognizer` does. +- No drag events are produced. `DragCallbacks` is keyed by pointer, and a trackpad gesture has no + per-finger pointers to key off, so a component that only uses `DragCallbacks` does not react to + the trackpad at all. If you pan a camera from drag events, handle `focalPointDelta` in + `onScaleUpdate` as well so that the trackpad works too. + +The `scaleThreshold` above applies here too: the platform reports the scale factor directly, so a +pinch crosses the threshold on its own, and a pure pan crosses it once the focal point has moved +past the platform's pan slop. + + ## Combining with DragCallbacks `ScaleCallbacks` and `DragCallbacks` can be used at the same time. Both are driven by the same diff --git a/doc/flame/migration.md b/doc/flame/migration.md index e3d65f1be7d..7a547d68694 100644 --- a/doc/flame/migration.md +++ b/doc/flame/migration.md @@ -417,11 +417,9 @@ class MyGame extends FlameGame with ScaleCallbacks, DragCallbacks { } ``` -Note that trackpad pinch gestures are not currently recognized by the new system: -`MultiDragScaleGestureRecognizer` does not yet handle Flutter's `PointerPanZoom` events, which is how -a trackpad pinch reaches a scale recognizer. Touchscreen pinches are unaffected. - -See [Scale Events](inputs/scale_events.md) for the full replacement API. +See [Scale Events](inputs/scale_events.md) for the full replacement API, including +[trackpad gestures](inputs/scale_events.md#trackpad-gestures), which the old detector never +supported. ### `MultiTouchTapDetector` and `MultiTouchDragDetector` removed 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 227bb69e55a..c83494e854a 100644 --- a/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart +++ b/packages/flame/lib/src/events/multi_drag_scale_recognizer.dart @@ -55,7 +55,20 @@ class MultiDragScaleGestureRecognizer({ final _DragState _drag = _DragState(); final _ScaleState _scale = _ScaleState(); - int get pointerCount => _drag.count; + /// The number of pointers taking part in the gesture. + /// + /// The `PointerPanZoom` protocol does not report how many fingers are on the + /// trackpad, only that a gesture is happening, so each trackpad gesture + /// counts as two -- the minimum it can be. This matches Flutter's + /// [ScaleGestureRecognizer]. + int get pointerCount => (2 * _scale.panZooms.length) + _drag.count; + + /// Arena entries for trackpad gestures that have not been accepted yet; drag + /// pointers keep theirs on their own [_DragPointerState]. + final Map _panZoomArenaEntries = {}; + + /// Whether enough inputs are present for a scale gesture at all. + bool get _canScale => pointerCount >= 2; @override void addAllowedPointer(PointerDownEvent event) { @@ -90,6 +103,62 @@ class MultiDragScaleGestureRecognizer({ } } + /// Trackpad gestures are only of interest when scale callbacks are in play; + /// they carry no per-finger pointers for drag callbacks to key off. + @override + bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) => + hasScale && super.isPointerPanZoomAllowed(event); + + @override + void addAllowedPointerPanZoom(PointerPanZoomStartEvent event) { + super.addAllowedPointerPanZoom(event); + assert( + !_scale.panZooms.containsKey(event.pointer), + 'Trackpad gesture ${event.pointer} is already tracked.', + ); + _scale.panZooms[event.pointer] = _PanZoomData.fromStart(event); + GestureBinding.instance.pointerRouter.addRoute( + event.pointer, + _handlePanZoomEvent, + ); + _panZoomArenaEntries[event.pointer] = GestureBinding.instance.gestureArena + .add(event.pointer, this); + + _scale.lastTransform = event.transform; + _updateScaleFields(); + _scale.initialFocalPoint = _scale.currentFocalPoint; + _scale.resetPanZoomBaseline(); + } + + void _handlePanZoomEvent(PointerEvent event) { + if (event is PointerPanZoomUpdateEvent) { + _scale.panZooms[event.pointer] = _PanZoomData.fromUpdate(event); + _scale.lastTransform = event.transform; + _updateScale( + timeStamp: event.timeStamp, + kind: event.kind, + ); + } else if (event is PointerPanZoomEndEvent) { + _scale.panZooms.remove(event.pointer); + _removePanZoom(event.pointer); + _scale.lastTransform = event.transform; + _updateScaleFields(); + _scale.resetPanZoomBaseline(); + _endScaleIfNeeded(); + } + } + + void _removePanZoom(int pointer) { + GestureBinding.instance.pointerRouter.removeRoute( + pointer, + _handlePanZoomEvent, + ); + // Still in the map means never accepted, so give the gesture up: left + // unresolved, the arena sweep would award it to us over a competing + // recognizer that wanted it. + _panZoomArenaEntries.remove(pointer)?.resolve(GestureDisposition.rejected); + } + void _handleEvent(PointerEvent event) { assert(_drag.pointers.containsKey(event.pointer)); final state = _drag.pointers[event.pointer]!; @@ -97,7 +166,8 @@ class MultiDragScaleGestureRecognizer({ if (event is PointerMoveEvent) { state._move(event); if (hasScale) { - _updateScale(event); + _scale.lastTransform = event.transform; + _updateScale(timeStamp: event.timeStamp, kind: event.kind); } } else if (event is PointerUpEvent) { assert(event.delta == Offset.zero); @@ -126,16 +196,18 @@ class MultiDragScaleGestureRecognizer({ } } - void _updateScale(PointerMoveEvent event) { - _scale.lastTransform = event.transform; + void _updateScale({ + required Duration timeStamp, + required PointerDeviceKind kind, + }) { _updateScaleFields(); _updateLines(); final focalPoint = _scale.currentFocalPoint!; - if (_drag.count >= 2 && !_scale.active && _checkScaleGestureThreshold()) { + if (_canScale && !_scale.active && _checkScaleGestureThreshold()) { _scale.active = true; - _scale.initialEventTimestamp = event.timeStamp; - _scale.velocityTracker = VelocityTracker.withKind(event.kind); + _scale.initialEventTimestamp = timeStamp; + _scale.velocityTracker = VelocityTracker.withKind(kind); if (onScaleStart != null) { invokeCallback('onScaleStart', () { @@ -151,8 +223,8 @@ class MultiDragScaleGestureRecognizer({ } } - if (_scale.active && _drag.count >= 2) { - _scale.velocityTracker?.addPosition(event.timeStamp, focalPoint); + if (_scale.active && _canScale) { + _scale.velocityTracker?.addPosition(timeStamp, focalPoint); if (onScaleUpdate != null) { invokeCallback('onScaleUpdate', () { @@ -166,7 +238,7 @@ class MultiDragScaleGestureRecognizer({ rotation: _computeRotationFactor(), pointerCount: pointerCount, focalPointDelta: _scale.delta, - sourceTimeStamp: event.timeStamp, + sourceTimeStamp: timeStamp, ), ); }); @@ -175,7 +247,7 @@ class MultiDragScaleGestureRecognizer({ } void _endScaleIfNeeded() { - if (!_scale.active || _drag.count >= 2) { + if (!_scale.active || _canScale) { return; } if (onScaleEnd != null) { @@ -224,11 +296,16 @@ class MultiDragScaleGestureRecognizer({ } bool _checkScaleGestureThreshold() { - if (_drag.pointers.isEmpty || _scale.initialFocalPoint == null) { + if (_scale.initialFocalPoint == null) { + return false; + } + final kind = + _drag.pointers.values.firstOrNull?.kind ?? + _scale.panZooms.values.firstOrNull?.kind; + if (kind == null) { return false; } - final kind = _drag.pointers.values.first.kind; final spanDelta = (_scale.currentSpan - _scale.initialSpan).abs(); final scaleFactor = _scale.scaleFactor; final focalDelta = @@ -242,6 +319,10 @@ class MultiDragScaleGestureRecognizer({ state._arenaEntry?.resolve(GestureDisposition.accepted); } } + for (final entry in _panZoomArenaEntries.values) { + entry.resolve(GestureDisposition.accepted); + } + _panZoomArenaEntries.clear(); return true; } return false; @@ -254,9 +335,13 @@ class MultiDragScaleGestureRecognizer({ for (final state in _drag.pointers.values) { focalPoint += state.currentPosition; } - _scale.currentFocalPoint = _drag.pointers.isEmpty + for (final panZoom in _scale.panZooms.values) { + focalPoint += panZoom.focalPoint; + } + final inputCount = _drag.pointers.length + _scale.panZooms.length; + _scale.currentFocalPoint = inputCount == 0 ? Offset.zero - : focalPoint / _drag.pointers.length.toDouble(); + : focalPoint / inputCount.toDouble(); if (previousFocalPoint == null) { _scale.localFocalPoint = PointerEvent.transformPosition( @@ -338,7 +423,10 @@ class MultiDragScaleGestureRecognizer({ final angle2 = math.atan2(nfy - nsy, nfx - nsx); factor = angle2 - angle1; } - return factor; + for (final panZoom in _scale.panZooms.values) { + factor += panZoom.rotation; + } + return factor - _scale.initialPanZoomRotationFactor; } bool _isFlingGesture(Velocity velocity) { @@ -392,6 +480,11 @@ class MultiDragScaleGestureRecognizer({ _removeState(pointer); } assert(_drag.pointers.isEmpty); + final panZoomPointers = _scale.panZooms.keys.toList(); + for (final pointer in panZoomPointers) { + _removePanZoom(pointer); + } + _scale.panZooms.clear(); super.dispose(); } @@ -406,6 +499,37 @@ class _DragState() { int get count => pointers.length; } +/// A single trackpad pan/zoom gesture, as reported by the `PointerPanZoom` +/// protocol. +/// +/// The protocol describes a whole gesture rather than individual fingers, so +/// one of these stands for two or more of them; see +/// [MultiDragScaleGestureRecognizer.pointerCount]. +class _PanZoomData { + _PanZoomData.fromStart(PointerPanZoomStartEvent event) + : position = event.position, + pan = Offset.zero, + scale = 1.0, + rotation = 0.0, + kind = event.kind; + + _PanZoomData.fromUpdate(PointerPanZoomUpdateEvent event) + : position = event.position, + pan = event.pan, + scale = event.scale, + rotation = event.rotation, + kind = event.kind; + + final Offset position; + final Offset pan; + final double scale; + final double rotation; + final PointerDeviceKind kind; + + /// Where the gesture currently is, panning included. + Offset get focalPoint => position + pan; +} + /// Groups all scale-tracking state for a [MultiDragScaleGestureRecognizer]. class _ScaleState() { bool active = false; @@ -425,16 +549,52 @@ class _ScaleState() { VelocityTracker? velocityTracker; Duration? initialEventTimestamp; + /// Live trackpad gestures, keyed by pointer id. + final Map panZooms = {}; + + /// The scale and rotation the trackpad gestures had contributed when the set + /// of inputs last changed, so that adding or removing one does not jump. + double initialPanZoomScaleFactor = 1.0; + double initialPanZoomRotationFactor = 0.0; + // 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 pointerScaleFactor { + return initialSpan > 0.0 ? currentSpan / initialSpan : 1.0; + } + + double get scaleFactor => _withPanZoom(pointerScaleFactor); + + double get horizontalScaleFactor => _withPanZoom( + initialHorizontalSpan > 0.0 + ? currentHorizontalSpan / initialHorizontalSpan + : 1.0, + ); + + double get verticalScaleFactor => _withPanZoom( + initialVerticalSpan > 0.0 ? currentVerticalSpan / initialVerticalSpan : 1.0, + ); - double get horizontalScaleFactor => initialHorizontalSpan > 0.0 - ? currentHorizontalSpan / initialHorizontalSpan - : 1.0; + double _withPanZoom(double pointerFactor) { + var scale = pointerFactor; + for (final panZoom in panZooms.values) { + scale *= panZoom.scale / initialPanZoomScaleFactor; + } + return scale; + } - double get verticalScaleFactor => initialVerticalSpan > 0.0 - ? currentVerticalSpan / initialVerticalSpan - : 1.0; + /// Re-baselines the trackpad contribution, so that the factors stay + /// continuous across a gesture starting or ending. + void resetPanZoomBaseline() { + if (panZooms.isEmpty) { + initialPanZoomScaleFactor = 1.0; + initialPanZoomRotationFactor = 0.0; + } else { + initialPanZoomScaleFactor = scaleFactor / pointerScaleFactor; + initialPanZoomRotationFactor = panZooms.values + .map((panZoom) => panZoom.rotation) + .reduce((a, b) => a + b); + } + } void reset() { active = false; diff --git a/packages/flame/test/events/component_mixins/trackpad_scale_callbacks_test.dart b/packages/flame/test/events/component_mixins/trackpad_scale_callbacks_test.dart new file mode 100644 index 00000000000..8ee30f2bd55 --- /dev/null +++ b/packages/flame/test/events/component_mixins/trackpad_scale_callbacks_test.dart @@ -0,0 +1,256 @@ +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Drives a whole trackpad pinch through the Flutter binding, the way the +/// engine reports one on desktop and web. +Future<_Gesture> _startPinch(WidgetTester tester, Offset at) async { + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(at)); + return _Gesture(tester, pointer, at); +} + +class _Gesture( + final WidgetTester tester, + final TestPointer pointer, + final Offset at, +) { + Future update({ + Offset pan = Offset.zero, + double scale = 1, + double rotation = 0, + }) { + return tester.sendEventToBinding( + pointer.panZoomUpdate( + at, + pan: pan, + scale: scale, + rotation: rotation, + ), + ); + } + + Future end() => tester.sendEventToBinding(pointer.panZoomEnd()); +} + +void main() { + group('trackpad pinch', () { + testWidgets('reaches ScaleCallbacks on the game', (tester) async { + final game = _ScaleGame(); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final pinch = await _startPinch(tester, const Offset(400, 300)); + // A trackpad reports scale directly, so the 5% default threshold is + // crossed by the scale factor alone with no movement. + await pinch.update(scale: 1.5); + + expect(game.starts, equals(1)); + expect(game.updates, greaterThan(0)); + expect(game.lastScale, closeTo(1.5, 1e-9)); + + await pinch.end(); + expect(game.ends, equals(1)); + }); + + testWidgets('reports at least two pointers', (tester) async { + final game = _ScaleGame(); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final pinch = await _startPinch(tester, const Offset(400, 300)); + await pinch.update(scale: 1.5); + + // The protocol never says how many fingers are involved, only that a + // gesture is happening, so it counts as the minimum of two. + expect(game.lastPointerCount, equals(2)); + + await pinch.end(); + }); + + testWidgets('a two-finger pan moves the focal point', (tester) async { + final game = _ScaleGame(); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final pinch = await _startPinch(tester, const Offset(400, 300)); + // Panning without pinching: the scale stays at 1 and the focal point + // travels, which is what pans a camera. + await pinch.update(pan: const Offset(60, 0)); + + expect(game.starts, equals(1)); + expect(game.lastScale, closeTo(1.0, 1e-9)); + expect(game.lastFocalPoint.x, greaterThan(400)); + + await pinch.end(); + }); + + testWidgets('rotation is reported', (tester) async { + final game = _ScaleGame(); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final pinch = await _startPinch(tester, const Offset(400, 300)); + await pinch.update(scale: 1.5, rotation: 0.5); + + expect(game.lastRotation, closeTo(0.5, 1e-9)); + + await pinch.end(); + }); + + testWidgets('below the threshold nothing fires', (tester) async { + final game = _ScaleGame(); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final pinch = await _startPinch(tester, const Offset(400, 300)); + // 1% is under the 5% default, and the focal point has not moved. + await pinch.update(scale: 1.01); + + expect(game.starts, equals(0)); + expect(game.updates, equals(0)); + + await pinch.end(); + expect(game.ends, equals(0)); + }); + + testWidgets('is ignored when only DragCallbacks are present', ( + tester, + ) async { + final game = _DragOnlyGame(); + await tester.pumpWidget(GameWidget(game: game)); + await tester.pump(); + await tester.pump(); + + final pinch = await _startPinch(tester, const Offset(400, 300)); + await pinch.update(scale: 1.5, pan: const Offset(60, 0)); + await pinch.end(); + + // A trackpad gesture has no per-finger pointers, so it is not turned + // into drags. + expect(game.dragStarts, equals(0)); + expect(game.dragUpdates, equals(0)); + }); + }); + + group('MultiDragScaleGestureRecognizer with a trackpad', () { + testWidgets('respects supportedDevices', (tester) async { + var started = false; + final recognizer = + MultiDragScaleGestureRecognizer( + supportedDevices: {PointerDeviceKind.touch}, + ) + ..hasScale = true + ..onScaleStart = (_) => started = true; + addTearDown(recognizer.dispose); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final start = pointer.panZoomStart(const Offset(400, 300)); + recognizer.addPointerPanZoom(start); + await tester.sendEventToBinding(start); + await tester.sendEventToBinding( + pointer.panZoomUpdate(const Offset(400, 300), scale: 1.5), + ); + await tester.sendEventToBinding(pointer.panZoomEnd()); + + expect(started, isFalse); + }); + + testWidgets( + 'gives up a gesture it never accepted, so a competitor can win it', + (tester) async { + final recognizer = MultiDragScaleGestureRecognizer()..hasScale = true; + addTearDown(recognizer.dispose); + final competitor = _ArenaProbe(); + addTearDown(competitor.dispose); + + final pointer = TestPointer(1, PointerDeviceKind.trackpad); + final start = pointer.panZoomStart(const Offset(400, 300)); + // Flame joins the arena first, so an unresolved entry would win the + // sweep at the end of the gesture. + recognizer.addPointerPanZoom(start); + competitor.addPointerPanZoom(start); + await tester.sendEventToBinding(start); + // Below the threshold: Flame never wants this gesture. + await tester.sendEventToBinding( + pointer.panZoomUpdate(const Offset(400, 300), scale: 1.01), + ); + await tester.sendEventToBinding(pointer.panZoomEnd()); + + expect(competitor.won, isTrue); + }, + ); + }); +} + +/// Joins the arena for a trackpad gesture and records whether it won. +class _ArenaProbe() extends GestureRecognizer { + bool? won; + + @override + void addAllowedPointerPanZoom(PointerPanZoomStartEvent event) { + GestureBinding.instance.gestureArena.add(event.pointer, this); + } + + @override + void acceptGesture(int pointer) => won = true; + + @override + void rejectGesture(int pointer) => won = false; + + @override + String get debugDescription => 'arena probe'; +} + +class _ScaleGame() extends FlameGame with ScaleCallbacks { + int starts = 0; + int updates = 0; + int ends = 0; + double lastScale = 0; + double lastRotation = 0; + int lastPointerCount = 0; + Vector2 lastFocalPoint = Vector2.zero(); + + @override + void onScaleStart(ScaleStartEvent event) { + super.onScaleStart(event); + starts++; + } + + @override + void onScaleUpdate(ScaleUpdateEvent event) { + updates++; + lastScale = event.scale; + lastRotation = event.rotation; + lastPointerCount = event.pointerCount; + lastFocalPoint = event.canvasEndPosition.clone(); + } + + @override + void onScaleEnd(ScaleEndEvent event) { + super.onScaleEnd(event); + ends++; + } +} + +class _DragOnlyGame() extends FlameGame with DragCallbacks { + int dragStarts = 0; + int dragUpdates = 0; + + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + dragStarts++; + } + + @override + void onDragUpdate(DragUpdateEvent event) { + dragUpdates++; + } +}