Unverified Commit 30a50180 authored by Callum Moffat's avatar Callum Moffat Committed by GitHub

Support trackpad gestures in framework (#89944)

* Implement trackpad gestures in framework

* Touch and Pan/Zoom pointers have separate IDs now

* Handle trackpad pointer device type

* Respect supportedDevices for pan/zoom events

* Update after rebase

* Fix check failures

* Avoid error with very short drags

* Address feedback

* Refactor drag event handler

* Address more feedback

* Add some missing punctuation
parent 38f360f9
......@@ -346,11 +346,11 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent || event is PointerPanZoomStartEvent) {
assert(!_hitTests.containsKey(event.pointer));
hitTestResult = HitTestResult();
hitTest(hitTestResult, event.position);
if (event is PointerDownEvent) {
if (event is PointerDownEvent || event is PointerPanZoomStartEvent) {
_hitTests[event.pointer] = hitTestResult;
}
assert(() {
......@@ -358,9 +358,9 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H
debugPrint('$event: $hitTestResult');
return true;
}());
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
} else if (event is PointerUpEvent || event is PointerCancelEvent || event is PointerPanZoomEndEvent) {
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
} else if (event.down || event is PointerPanZoomUpdateEvent) {
// Because events that occur with the pointer down (like
// [PointerMoveEvent]s) should be dispatched to the same place that their
// initial PointerDownEvent was, we want to re-use the path we found when
......@@ -443,9 +443,9 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H
@override // from HitTestTarget
void handleEvent(PointerEvent event, HitTestEntry entry) {
pointerRouter.route(event);
if (event is PointerDownEvent) {
if (event is PointerDownEvent || event is PointerPanZoomStartEvent) {
gestureArena.close(event.pointer);
} else if (event is PointerUpEvent) {
} else if (event is PointerUpEvent || event is PointerPanZoomEndEvent) {
gestureArena.sweep(event.pointer);
} else if (event is PointerSignalEvent) {
pointerSignalResolver.resolve(event);
......
......@@ -15,14 +15,13 @@ import 'events.dart';
int _synthesiseDownButtons(int buttons, PointerDeviceKind kind) {
switch (kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
return buttons;
case PointerDeviceKind.touch:
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
return buttons == 0 ? kPrimaryButton : buttons;
case PointerDeviceKind.unknown:
default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
// We have no information about the device but we know we never want
// buttons to be 0 when the pointer is down.
return buttons == 0 ? kPrimaryButton : buttons;
......@@ -209,9 +208,44 @@ class PointerEventConverter {
radiusMax: radiusMax,
embedderId: datum.embedderId,
);
default: // ignore: no_default_cases, to allow adding new pointer events to [ui.PointerChange]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
throw StateError('Unreachable');
case ui.PointerChange.panZoomStart:
return PointerPanZoomStartEvent(
timeStamp: timeStamp,
pointer: datum.pointerIdentifier,
kind: kind,
device: datum.device,
position: position,
embedderId: datum.embedderId,
synthesized: datum.synthesized,
);
case ui.PointerChange.panZoomUpdate:
final Offset pan =
Offset(datum.panX, datum.panY) / devicePixelRatio;
final Offset panDelta =
Offset(datum.panDeltaX, datum.panDeltaY) / devicePixelRatio;
return PointerPanZoomUpdateEvent(
timeStamp: timeStamp,
pointer: datum.pointerIdentifier,
kind: kind,
device: datum.device,
position: position,
pan: pan,
panDelta: panDelta,
scale: datum.scale,
rotation: datum.rotation,
embedderId: datum.embedderId,
synthesized: datum.synthesized,
);
case ui.PointerChange.panZoomEnd:
return PointerPanZoomEndEvent(
timeStamp: timeStamp,
pointer: datum.pointerIdentifier,
kind: kind,
device: datum.device,
position: position,
embedderId: datum.embedderId,
synthesized: datum.synthesized,
);
}
case ui.PointerSignalKind.scroll:
final Offset scrollDelta =
......
......@@ -261,14 +261,11 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
return super.isPointerAllowed(event as PointerDownEvent);
}
@override
void addAllowedPointer(PointerDownEvent event) {
super.addAllowedPointer(event);
void _addPointer(PointerEvent event) {
_velocityTrackers[event.pointer] = velocityTrackerBuilder(event);
if (_state == _DragState.ready) {
_state = _DragState.possible;
_initialPosition = OffsetPair(global: event.position, local: event.localPosition);
_initialButtons = event.buttons;
_pendingDragOffset = OffsetPair.zero;
_globalDistanceMoved = 0.0;
_lastPendingEventTimestamp = event.timeStamp;
......@@ -279,45 +276,76 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
}
}
@override
void addAllowedPointer(PointerDownEvent event) {
super.addAllowedPointer(event);
if (_state == _DragState.ready) {
_initialButtons = event.buttons;
}
_addPointer(event);
}
@override
void addAllowedPointerPanZoom(PointerPanZoomStartEvent event) {
super.addAllowedPointerPanZoom(event);
startTrackingPointer(event.pointer, event.transform);
if (_state == _DragState.ready) {
_initialButtons = kPrimaryButton;
}
_addPointer(event);
}
@override
void handleEvent(PointerEvent event) {
assert(_state != _DragState.ready);
if (!event.synthesized
&& (event is PointerDownEvent || event is PointerMoveEvent)) {
if (!event.synthesized &&
(event is PointerDownEvent ||
event is PointerMoveEvent ||
event is PointerPanZoomStartEvent ||
event is PointerPanZoomUpdateEvent)) {
final VelocityTracker tracker = _velocityTrackers[event.pointer]!;
assert(tracker != null);
tracker.addPosition(event.timeStamp, event.localPosition);
}
if (event is PointerMoveEvent) {
if (event.buttons != _initialButtons) {
_giveUpPointer(event.pointer);
return;
if (event is PointerPanZoomStartEvent) {
tracker.addPosition(event.timeStamp, Offset.zero);
} else if (event is PointerPanZoomUpdateEvent) {
tracker.addPosition(event.timeStamp, event.pan);
} else {
tracker.addPosition(event.timeStamp, event.localPosition);
}
}
if (event is PointerMoveEvent && event.buttons != _initialButtons) {
_giveUpPointer(event.pointer);
return;
}
if (event is PointerMoveEvent || event is PointerPanZoomUpdateEvent) {
final Offset delta = (event is PointerMoveEvent) ? event.delta : (event as PointerPanZoomUpdateEvent).panDelta;
final Offset localDelta = (event is PointerMoveEvent) ? event.localDelta : (event as PointerPanZoomUpdateEvent).localPanDelta;
final Offset position = (event is PointerMoveEvent) ? event.position : (event.position + (event as PointerPanZoomUpdateEvent).pan);
final Offset localPosition = (event is PointerMoveEvent) ? event.localPosition : (event.localPosition + (event as PointerPanZoomUpdateEvent).localPan);
if (_state == _DragState.accepted) {
_checkUpdate(
sourceTimeStamp: event.timeStamp,
delta: _getDeltaForDetails(event.localDelta),
primaryDelta: _getPrimaryValueFromOffset(event.localDelta),
globalPosition: event.position,
localPosition: event.localPosition,
delta: _getDeltaForDetails(localDelta),
primaryDelta: _getPrimaryValueFromOffset(localDelta),
globalPosition: position,
localPosition: localPosition,
);
} else {
_pendingDragOffset += OffsetPair(local: event.localDelta, global: event.delta);
_pendingDragOffset += OffsetPair(local: localDelta, global: delta);
_lastPendingEventTimestamp = event.timeStamp;
_lastTransform = event.transform;
final Offset movedLocally = _getDeltaForDetails(event.localDelta);
final Offset movedLocally = _getDeltaForDetails(localDelta);
final Matrix4? localToGlobalTransform = event.transform == null ? null : Matrix4.tryInvert(event.transform!);
_globalDistanceMoved += PointerEvent.transformDeltaViaPositions(
transform: localToGlobalTransform,
untransformedDelta: movedLocally,
untransformedEndPosition: event.localPosition,
untransformedEndPosition: localPosition
).distance * (_getPrimaryValueFromOffset(movedLocally) ?? 1).sign;
if (_hasSufficientGlobalDistanceToAccept(event.kind, gestureSettings?.touchSlop))
resolve(GestureDisposition.accepted);
}
}
if (event is PointerUpEvent || event is PointerCancelEvent) {
if (event is PointerUpEvent || event is PointerCancelEvent || event is PointerPanZoomEndEvent) {
_giveUpPointer(event.pointer);
}
}
......
......@@ -96,6 +96,43 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT
/// coming from.
final Map<int, PointerDeviceKind> _pointerToKind = <int, PointerDeviceKind>{};
/// Registers a new pointer pan/zoom that might be relevant to this gesture
/// detector.
///
/// A pointer pan/zoom is a stream of events that conveys data covering
/// pan, zoom, and rotate data from a multi-finger trackpad gesture.
///
/// The owner of this gesture recognizer calls addPointerPanZoom() with the
/// PointerPanZoomStartEvent of each pointer that should be considered for
/// this gesture.
///
/// It's the GestureRecognizer's responsibility to then add itself
/// to the global pointer router (see [PointerRouter]) to receive
/// subsequent events for this pointer, and to add the pointer to
/// the global gesture arena manager (see [GestureArenaManager]) to track
/// that pointer.
///
/// This method is called for each and all pointers being added. In
/// most cases, you want to override [addAllowedPointerPanZoom] instead.
void addPointerPanZoom(PointerPanZoomStartEvent event) {
_pointerToKind[event.pointer] = event.kind;
if (isPointerPanZoomAllowed(event)) {
addAllowedPointerPanZoom(event);
} else {
handleNonAllowedPointerPanZoom(event);
}
}
/// Registers a new pointer pan/zoom that's been checked to be allowed by this
/// gesture recognizer.
///
/// Subclasses of [GestureRecognizer] are supposed to override this method
/// instead of [addPointerPanZoom] because [addPointerPanZoom] will be called for each
/// pointer being added while [addAllowedPointerPanZoom] is only called for pointers
/// that are allowed by this recognizer.
@protected
void addAllowedPointerPanZoom(PointerPanZoomStartEvent event) { }
/// Registers a new pointer that might be relevant to this gesture
/// detector.
///
......@@ -147,6 +184,18 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT
return _supportedDevices == null || _supportedDevices!.contains(event.kind);
}
/// Handles a pointer pan/zoom being added that's not allowed by this recognizer.
///
/// Subclasses can override this method and reject the gesture.
@protected
void handleNonAllowedPointerPanZoom(PointerPanZoomStartEvent event) { }
/// Checks whether or not a pointer pan/zoom is allowed to be tracked by this recognizer.
@protected
bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) {
return _supportedDevices == null || _supportedDevices!.contains(event.kind);
}
/// For a given pointer ID, returns the device kind associated with it.
///
/// The pointer ID is expected to be a valid one i.e. an event was received
......@@ -397,7 +446,7 @@ abstract class OneSequenceGestureRecognizer extends GestureRecognizer {
/// a [PointerUpEvent] or a [PointerCancelEvent] event.
@protected
void stopTrackingIfPointerNoLongerDown(PointerEvent event) {
if (event is PointerUpEvent || event is PointerCancelEvent)
if (event is PointerUpEvent || event is PointerCancelEvent || event is PointerPanZoomEndEvent)
stopTrackingPointer(event.pointer);
}
}
......
......@@ -2858,6 +2858,21 @@ typedef PointerUpEventListener = void Function(PointerUpEvent event);
/// Used by [Listener] and [RenderPointerListener].
typedef PointerCancelEventListener = void Function(PointerCancelEvent event);
/// Signature for listening to [PointerPanZoomStartEvent] events.
///
/// Used by [Listener] and [RenderPointerListener].
typedef PointerPanZoomStartEventListener = void Function(PointerPanZoomStartEvent event);
/// Signature for listening to [PointerPanZoomUpdateEvent] events.
///
/// Used by [Listener] and [RenderPointerListener].
typedef PointerPanZoomUpdateEventListener = void Function(PointerPanZoomUpdateEvent event);
/// Signature for listening to [PointerPanZoomEndEvent] events.
///
/// Used by [Listener] and [RenderPointerListener].
typedef PointerPanZoomEndEventListener = void Function(PointerPanZoomEndEvent event);
/// Signature for listening to [PointerSignalEvent] events.
///
/// Used by [Listener] and [RenderPointerListener].
......@@ -2885,6 +2900,9 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
this.onPointerUp,
this.onPointerHover,
this.onPointerCancel,
this.onPointerPanZoomStart,
this.onPointerPanZoomUpdate,
this.onPointerPanZoomEnd,
this.onPointerSignal,
HitTestBehavior behavior = HitTestBehavior.deferToChild,
RenderBox? child,
......@@ -2909,6 +2927,15 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
/// no longer directed towards this receiver.
PointerCancelEventListener? onPointerCancel;
/// Called when a pan/zoom begins such as from a trackpad gesture.
PointerPanZoomStartEventListener? onPointerPanZoomStart;
/// Called when a pan/zoom is updated.
PointerPanZoomUpdateEventListener? onPointerPanZoomUpdate;
/// Called when a pan/zoom finishes.
PointerPanZoomEndEventListener? onPointerPanZoomEnd;
/// Called when a pointer signal occurs over this object.
PointerSignalEventListener? onPointerSignal;
......@@ -2930,6 +2957,12 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
return onPointerHover?.call(event);
if (event is PointerCancelEvent)
return onPointerCancel?.call(event);
if (event is PointerPanZoomStartEvent)
return onPointerPanZoomStart?.call(event);
if (event is PointerPanZoomUpdateEvent)
return onPointerPanZoomUpdate?.call(event);
if (event is PointerPanZoomEndEvent)
return onPointerPanZoomEnd?.call(event);
if (event is PointerSignalEvent)
return onPointerSignal?.call(event);
}
......@@ -2945,6 +2978,9 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
'up': onPointerUp,
'hover': onPointerHover,
'cancel': onPointerCancel,
'panZoomStart': onPointerPanZoomStart,
'panZoomUpdate': onPointerPanZoomUpdate,
'panZoomEnd': onPointerPanZoomEnd,
'signal': onPointerSignal,
},
ifEmpty: '<none>',
......
......@@ -634,6 +634,7 @@ class _AndroidMotionEventConverter {
int toolType = AndroidPointerProperties.kToolTypeUnknown;
switch (event.kind) {
case PointerDeviceKind.touch:
case PointerDeviceKind.trackpad:
toolType = AndroidPointerProperties.kToolTypeFinger;
break;
case PointerDeviceKind.mouse:
......@@ -646,8 +647,6 @@ class _AndroidMotionEventConverter {
toolType = AndroidPointerProperties.kToolTypeEraser;
break;
case PointerDeviceKind.unknown:
default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
toolType = AndroidPointerProperties.kToolTypeUnknown;
break;
}
......
......@@ -6090,6 +6090,9 @@ class Listener extends SingleChildRenderObjectWidget {
this.onPointerUp,
this.onPointerHover,
this.onPointerCancel,
this.onPointerPanZoomStart,
this.onPointerPanZoomUpdate,
this.onPointerPanZoomEnd,
this.onPointerSignal,
this.behavior = HitTestBehavior.deferToChild,
Widget? child,
......@@ -6119,6 +6122,15 @@ class Listener extends SingleChildRenderObjectWidget {
/// no longer directed towards this receiver.
final PointerCancelEventListener? onPointerCancel;
/// Called when a pan/zoom begins such as from a trackpad gesture.
final PointerPanZoomStartEventListener? onPointerPanZoomStart;
/// Called when a pan/zoom is updated.
final PointerPanZoomUpdateEventListener? onPointerPanZoomUpdate;
/// Called when a pan/zoom finishes.
final PointerPanZoomEndEventListener? onPointerPanZoomEnd;
/// Called when a pointer signal occurs over this object.
///
/// See also:
......@@ -6138,6 +6150,9 @@ class Listener extends SingleChildRenderObjectWidget {
onPointerUp: onPointerUp,
onPointerHover: onPointerHover,
onPointerCancel: onPointerCancel,
onPointerPanZoomStart: onPointerPanZoomStart,
onPointerPanZoomUpdate: onPointerPanZoomUpdate,
onPointerPanZoomEnd: onPointerPanZoomEnd,
onPointerSignal: onPointerSignal,
behavior: behavior,
);
......@@ -6151,6 +6166,9 @@ class Listener extends SingleChildRenderObjectWidget {
..onPointerUp = onPointerUp
..onPointerHover = onPointerHover
..onPointerCancel = onPointerCancel
..onPointerPanZoomStart = onPointerPanZoomStart
..onPointerPanZoomUpdate = onPointerPanZoomUpdate
..onPointerPanZoomEnd = onPointerPanZoomEnd
..onPointerSignal = onPointerSignal
..behavior = behavior;
}
......@@ -6164,6 +6182,9 @@ class Listener extends SingleChildRenderObjectWidget {
if (onPointerUp != null) 'up',
if (onPointerHover != null) 'hover',
if (onPointerCancel != null) 'cancel',
if (onPointerPanZoomStart != null) 'panZoomStart',
if (onPointerPanZoomUpdate != null) 'panZoomUpdate',
if (onPointerPanZoomEnd != null) 'panZoomEnd',
if (onPointerSignal != null) 'signal',
];
properties.add(IterableProperty<String>('listeners', listeners, ifEmpty: '<none>'));
......
......@@ -1648,9 +1648,8 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
expectedMode = FocusHighlightMode.touch;
break;
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
case PointerDeviceKind.unknown:
default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
_lastInteractionWasTouch = false;
expectedMode = FocusHighlightMode.traditional;
break;
......
......@@ -1435,6 +1435,13 @@ class RawGestureDetectorState extends State<RawGestureDetector> {
recognizer.addPointer(event);
}
void _handlePointerPanZoomStart(PointerPanZoomStartEvent event) {
assert(_recognizers != null);
for (final GestureRecognizer recognizer in _recognizers!.values) {
recognizer.addPointerPanZoom(event);
}
}
HitTestBehavior get _defaultBehavior {
return widget.child == null ? HitTestBehavior.translucent : HitTestBehavior.deferToChild;
}
......@@ -1449,6 +1456,7 @@ class RawGestureDetectorState extends State<RawGestureDetector> {
Widget build(BuildContext context) {
Widget result = Listener(
onPointerDown: _handlePointerDown,
onPointerPanZoomStart: _handlePointerPanZoomStart,
behavior: widget.behavior ?? _defaultBehavior,
child: widget.child,
);
......
......@@ -19,6 +19,7 @@ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
PointerDeviceKind.trackpad,
// The VoiceAccess sends pointer events with unknown type when scrolling
// scrollables.
PointerDeviceKind.unknown,
......
......@@ -682,13 +682,12 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
switch (kind) {
case PointerDeviceKind.touch:
case PointerDeviceKind.trackpad:
return paddedRect.contains(position);
case PointerDeviceKind.mouse:
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
return interactiveRect.contains(position);
}
}
......@@ -713,6 +712,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
switch (kind) {
case PointerDeviceKind.touch:
case PointerDeviceKind.trackpad:
final Rect touchThumbRect = _thumbRect!.expandToInclude(
Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2),
);
......@@ -721,8 +721,6 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
return _thumbRect!.contains(position);
}
}
......@@ -1963,6 +1961,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
onExit: (PointerExitEvent event) {
switch(event.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
if (enableGestures)
handleHoverExit(event);
break;
......@@ -1970,14 +1969,13 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
case PointerDeviceKind.touch:
default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
break;
}
},
onHover: (PointerHoverEvent event) {
switch(event.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
if (enableGestures)
handleHover(event);
break;
......@@ -1985,8 +1983,6 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
case PointerDeviceKind.touch:
default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
break;
}
},
......
......@@ -1525,6 +1525,7 @@ class TextSelectionGestureDetectorBuilder {
case TargetPlatform.macOS:
switch (details.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
// Precise devices should place the cursor at a precise position.
......@@ -1532,8 +1533,6 @@ class TextSelectionGestureDetectorBuilder {
break;
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
// On macOS/iOS/iPadOS a touch tap places the cursor at the edge
// of the word.
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
......
......@@ -1484,4 +1484,219 @@ void main() {
tap2.dispose();
});
testGesture('Should recognize pan gestures from platform', (GestureTester tester) {
final PanGestureRecognizer pan = PanGestureRecognizer();
// We need a competing gesture recognizer so that the gesture is not immediately claimed.
final PanGestureRecognizer competingPan = PanGestureRecognizer();
addTearDown(pan.dispose);
addTearDown(competingPan.dispose);
bool didStartPan = false;
pan.onStart = (_) {
didStartPan = true;
};
Offset? updatedScrollDelta;
pan.onUpdate = (DragUpdateDetails details) {
updatedScrollDelta = details.delta;
};
bool didEndPan = false;
pan.onEnd = (DragEndDetails details) {
didEndPan = true;
};
final TestPointer pointer = TestPointer(2);
final PointerPanZoomStartEvent start = pointer.panZoomStart(const Offset(10.0, 10.0));
pan.addPointerPanZoom(start);
competingPan.addPointerPanZoom(start);
tester.closeArena(2);
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
tester.route(start);
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
// Gesture will be claimed when distance reaches kPanSlop, which was 36.0 when this test was last updated.
tester.route(pointer.panZoomUpdate(const Offset(10.0, 10.0), pan: const Offset(20.0, 20.0))); // moved 20 horizontally and 20 vertically which is 28 total
expect(didStartPan, isFalse); // 28 < 36
tester.route(pointer.panZoomUpdate(const Offset(10.0, 10.0), pan: const Offset(30.0, 30.0))); // moved 30 horizontally and 30 vertically which is 42 total
expect(didStartPan, isTrue); // 42 > 36
didStartPan = false;
expect(didEndPan, isFalse);
tester.route(pointer.panZoomUpdate(const Offset(10.0, 10.0), pan: const Offset(30.0, 25.0)));
expect(didStartPan, isFalse);
expect(updatedScrollDelta, const Offset(0.0, -5.0));
updatedScrollDelta = null;
expect(didEndPan, isFalse);
tester.route(pointer.panZoomEnd());
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isTrue);
didEndPan = false;
});
testGesture('Pointer pan/zooms drags should allow touches to join them', (GestureTester tester) {
final PanGestureRecognizer pan = PanGestureRecognizer();
// We need a competing gesture recognizer so that the gesture is not immediately claimed.
final PanGestureRecognizer competingPan = PanGestureRecognizer();
addTearDown(pan.dispose);
addTearDown(competingPan.dispose);
bool didStartPan = false;
pan.onStart = (_) {
didStartPan = true;
};
Offset? updatedScrollDelta;
pan.onUpdate = (DragUpdateDetails details) {
updatedScrollDelta = details.delta;
};
bool didEndPan = false;
pan.onEnd = (DragEndDetails details) {
didEndPan = true;
};
final TestPointer panZoomPointer = TestPointer(2);
final TestPointer touchPointer = TestPointer(3);
final PointerPanZoomStartEvent start = panZoomPointer.panZoomStart(const Offset(10.0, 10.0));
pan.addPointerPanZoom(start);
competingPan.addPointerPanZoom(start);
tester.closeArena(2);
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
tester.route(start);
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
// Gesture will be claimed when distance reaches kPanSlop, which was 36.0 when this test was last updated.
tester.route(panZoomPointer.panZoomUpdate(const Offset(10.0, 10.0), pan: const Offset(20.0, 20.0))); // moved 20 horizontally and 20 vertically which is 28 total
expect(didStartPan, isFalse); // 28 < 36
tester.route(panZoomPointer.panZoomUpdate(const Offset(10.0, 10.0), pan: const Offset(30.0, 30.0))); // moved 30 horizontally and 30 vertically which is 42 total
expect(didStartPan, isTrue); // 42 > 36
didStartPan = false;
expect(didEndPan, isFalse);
tester.route(panZoomPointer.panZoomUpdate(const Offset(10.0, 10.0), pan: const Offset(30.0, 25.0)));
expect(didStartPan, isFalse);
expect(updatedScrollDelta, const Offset(0.0, -5.0));
updatedScrollDelta = null;
expect(didEndPan, isFalse);
final PointerDownEvent touchDown = touchPointer.down(const Offset(20.0, 20.0));
pan.addPointer(touchDown);
competingPan.addPointer(touchDown);
tester.closeArena(3);
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
tester.route(touchPointer.move(const Offset(25.0, 25.0)));
expect(didStartPan, isFalse);
expect(updatedScrollDelta, const Offset(5.0, 5.0));
updatedScrollDelta = null;
expect(didEndPan, isFalse);
tester.route(touchPointer.up());
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
tester.route(panZoomPointer.panZoomEnd());
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isTrue);
didEndPan = false;
});
testGesture('Touch drags should allow pointer pan/zooms to join them', (GestureTester tester) {
final PanGestureRecognizer pan = PanGestureRecognizer();
// We need a competing gesture recognizer so that the gesture is not immediately claimed.
final PanGestureRecognizer competingPan = PanGestureRecognizer();
addTearDown(pan.dispose);
addTearDown(competingPan.dispose);
bool didStartPan = false;
pan.onStart = (_) {
didStartPan = true;
};
Offset? updatedScrollDelta;
pan.onUpdate = (DragUpdateDetails details) {
updatedScrollDelta = details.delta;
};
bool didEndPan = false;
pan.onEnd = (DragEndDetails details) {
didEndPan = true;
};
final TestPointer panZoomPointer = TestPointer(2);
final TestPointer touchPointer = TestPointer(3);
final PointerDownEvent touchDown = touchPointer.down(const Offset(20.0, 20.0));
pan.addPointer(touchDown);
competingPan.addPointer(touchDown);
tester.closeArena(3);
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
tester.route(touchPointer.move(const Offset(60.0, 60.0)));
expect(didStartPan, isTrue);
didStartPan = false;
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
tester.route(touchPointer.move(const Offset(70.0, 70.0)));
expect(didStartPan, isFalse);
expect(updatedScrollDelta, const Offset(10.0, 10.0));
updatedScrollDelta = null;
expect(didEndPan, isFalse);
final PointerPanZoomStartEvent start = panZoomPointer.panZoomStart(const Offset(10.0, 10.0));
pan.addPointerPanZoom(start);
competingPan.addPointerPanZoom(start);
tester.closeArena(2);
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
tester.route(start);
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
// Gesture will be claimed when distance reaches kPanSlop, which was 36.0 when this test was last updated.
tester.route(panZoomPointer.panZoomUpdate(const Offset(10.0, 10.0), pan: const Offset(20.0, 20.0))); // moved 20 horizontally and 20 vertically which is 28 total
expect(didStartPan, isFalse);
expect(updatedScrollDelta, const Offset(20.0, 20.0));
updatedScrollDelta = null;
expect(didEndPan, isFalse);
tester.route(panZoomPointer.panZoomUpdate(const Offset(10.0, 10.0), pan: const Offset(30.0, 30.0))); // moved 30 horizontally and 30 vertically which is 42 total
expect(didStartPan, isFalse);
expect(updatedScrollDelta, const Offset(10.0, 10.0));
updatedScrollDelta = null;
expect(didEndPan, isFalse);
tester.route(panZoomPointer.panZoomEnd());
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isFalse);
tester.route(touchPointer.up());
expect(didStartPan, isFalse);
expect(updatedScrollDelta, isNull);
expect(didEndPan, isTrue);
didEndPan = false;
});
}
......@@ -505,6 +505,39 @@ void main() {
localPosition: localPosition,
);
const PointerPanZoomStartEvent panZoomStart = PointerPanZoomStartEvent(
timeStamp: Duration(seconds: 2),
device: 1,
position: Offset(20, 30),
);
_expectTransformedEvent(
original: panZoomStart,
transform: transform,
localPosition: localPosition,
);
const PointerPanZoomUpdateEvent panZoomUpdate = PointerPanZoomUpdateEvent(
timeStamp: Duration(seconds: 2),
device: 1,
position: Offset(20, 30),
);
_expectTransformedEvent(
original: panZoomUpdate,
transform: transform,
localPosition: localPosition,
);
const PointerPanZoomEndEvent panZoomEnd = PointerPanZoomEndEvent(
timeStamp: Duration(seconds: 2),
device: 1,
position: Offset(20, 30),
);
_expectTransformedEvent(
original: panZoomEnd,
transform: transform,
localPosition: localPosition,
);
const PointerUpEvent up = PointerUpEvent(
timeStamp: Duration(seconds: 2),
pointer: 45,
......
......@@ -335,4 +335,23 @@ void main() {
expect(events[4].buttons, equals(0));
}
});
test('Pointer pan/zoom events', () {
const ui.PointerDataPacket packet = ui.PointerDataPacket(
data: <ui.PointerData>[
ui.PointerData(change: ui.PointerChange.panZoomStart),
ui.PointerData(change: ui.PointerChange.panZoomUpdate),
ui.PointerData(change: ui.PointerChange.panZoomEnd),
],
);
final List<PointerEvent> events = <PointerEvent>[];
binding.callback = events.add;
ui.window.onPointerDataPacket?.call(packet);
expect(events.length, 3);
expect(events[0], isA<PointerPanZoomStartEvent>());
expect(events[1], isA<PointerPanZoomUpdateEvent>());
expect(events[2], isA<PointerPanZoomEndEvent>());
});
}
......@@ -272,7 +272,7 @@ void main() {
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n'
' │ behavior: opaque\n'
' │ listeners: down\n'
' │ listeners: down, panZoomStart\n'
' │\n'
' └─child: RenderSemanticsAnnotations#00000\n'
' │ needs compositing\n'
......@@ -432,7 +432,7 @@ void main() {
' │ constraints: BoxConstraints(w=800.0, h=600.0)\n'
' │ size: Size(800.0, 600.0)\n'
' │ behavior: opaque\n'
' │ listeners: down\n'
' │ listeners: down, panZoomStart\n'
' │\n'
' └─child: RenderSemanticsAnnotations#00000\n'
' │ needs compositing\n'
......
......@@ -134,6 +134,7 @@ void main() {
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
PointerDeviceKind.trackpad,
PointerDeviceKind.unknown,
});
......
......@@ -33,9 +33,8 @@ class TestPointer {
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.touch:
case PointerDeviceKind.trackpad:
case PointerDeviceKind.unknown:
default: // ignore: no_default_cases, to allow adding new device types to [PointerDeviceKind]
// TODO(moffatman): Remove after landing https://github.com/flutter/flutter/issues/23604
_device = device ?? 0;
break;
}
......@@ -70,12 +69,27 @@ class TestPointer {
bool get isDown => _isDown;
bool _isDown = false;
/// Whether the pointer simulated by this object currently has
/// an active pan/zoom gesture.
///
/// A pan/zoom gesture begins when [panZoomStart] is called, and
/// ends when [panZoomEnd] is called.
bool get isPanZoomActive => _isPanZoomActive;
bool _isPanZoomActive = false;
/// The position of the last event sent by this object.
///
/// If no event has ever been sent by this object, returns null.
Offset? get location => _location;
Offset? _location;
/// The pan offset of the last pointer pan/zoom event sent by this object.
///
/// If no pan/zoom event has ever been sent by this object, returns null.
Offset? get pan => _pan;
Offset? _pan;
/// If a custom event is created outside of this class, this function is used
/// to set the [isDown].
bool setDownInfo(
......@@ -115,6 +129,7 @@ class TestPointer {
int? buttons,
}) {
assert(!isDown);
assert(!isPanZoomActive);
_isDown = true;
_location = newLocation;
if (buttons != null)
......@@ -149,6 +164,7 @@ class TestPointer {
'Move events can only be generated when the pointer is down. To '
'create a movement event simulating a pointer move when the pointer is '
'up, use hover() instead.');
assert(!isPanZoomActive);
final Offset delta = newLocation - location!;
_location = newLocation;
if (buttons != null)
......@@ -171,6 +187,7 @@ class TestPointer {
///
/// The object is no longer usable after this method has been called.
PointerUpEvent up({ Duration timeStamp = Duration.zero }) {
assert(!isPanZoomActive);
assert(isDown);
_isDown = false;
return PointerUpEvent(
......@@ -283,6 +300,79 @@ class TestPointer {
scrollDelta: scrollDelta,
);
}
/// Create a [PointerPanZoomStartEvent] (e.g., trackpad scroll; not scroll wheel
/// or finger-drag scroll) with the given delta.
///
/// By default, the time stamp on the event is [Duration.zero]. You can give a
/// specific time stamp by passing the `timeStamp` argument.
PointerPanZoomStartEvent panZoomStart(
Offset location, {
Duration timeStamp = Duration.zero
}) {
assert(!isPanZoomActive);
_location = location;
_pan = Offset.zero;
_isPanZoomActive = true;
return PointerPanZoomStartEvent(
timeStamp: timeStamp,
kind: kind,
device: _device,
pointer: pointer,
position: location,
);
}
/// Create a [PointerPanZoomUpdateEvent] to update the active pan/zoom sequence
/// on this pointer with updated pan, scale, and/or rotation values.
///
/// [rotation] is in units of radians.
///
/// By default, the time stamp on the event is [Duration.zero]. You can give a
/// specific time stamp by passing the `timeStamp` argument.
PointerPanZoomUpdateEvent panZoomUpdate(
Offset location, {
Offset pan = Offset.zero,
double scale = 1,
double rotation = 0,
Duration timeStamp = Duration.zero,
}) {
assert(isPanZoomActive);
_location = location;
final Offset panDelta = pan - _pan!;
_pan = pan;
return PointerPanZoomUpdateEvent(
timeStamp: timeStamp,
kind: kind,
device: _device,
pointer: pointer,
position: location,
pan: pan,
panDelta: panDelta,
scale: scale,
rotation: rotation,
);
}
/// Create a [PointerPanZoomEndEvent] to end the active pan/zoom sequence
/// on this pointer.
///
/// By default, the time stamp on the event is [Duration.zero]. You can give a
/// specific time stamp by passing the `timeStamp` argument.
PointerPanZoomEndEvent panZoomEnd({
Duration timeStamp = Duration.zero
}) {
assert(isPanZoomActive);
_isPanZoomActive = false;
_pan = null;
return PointerPanZoomEndEvent(
timeStamp: timeStamp,
kind: kind,
device: _device,
pointer: pointer,
position: location!,
);
}
}
/// Signature for a callback that can dispatch events and returns a future that
......
......@@ -45,5 +45,7 @@
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>
......@@ -45,5 +45,7 @@
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>
......@@ -1383,6 +1383,8 @@ void main() {
expect(plistFile, exists);
final bool disabled = _getBooleanValueFromPlist(plistFile: plistFile, key: 'CADisableMinimumFrameDurationOnPhone');
expect(disabled, isTrue);
final bool indirectInput = _getBooleanValueFromPlist(plistFile: plistFile, key: 'UIApplicationSupportsIndirectInputEvents');
expect(indirectInput, isTrue);
final String displayName = _getStringValueFromPlist(plistFile: plistFile, key: 'CFBundleDisplayName');
expect(displayName, 'My Project');
});
......@@ -1400,6 +1402,8 @@ void main() {
expect(plistFile, exists);
final bool disabled = _getBooleanValueFromPlist(plistFile: plistFile, key: 'CADisableMinimumFrameDurationOnPhone');
expect(disabled, isTrue);
final bool indirectInput = _getBooleanValueFromPlist(plistFile: plistFile, key: 'UIApplicationSupportsIndirectInputEvents');
expect(indirectInput, isTrue);
final String displayName = _getStringValueFromPlist(plistFile: plistFile, key: 'CFBundleDisplayName');
expect(displayName, 'My Project');
});
......@@ -1417,6 +1421,8 @@ void main() {
expect(plistFile, exists);
final bool disabled = _getBooleanValueFromPlist(plistFile: plistFile, key: 'CADisableMinimumFrameDurationOnPhone');
expect(disabled, isTrue);
final bool indirectInput = _getBooleanValueFromPlist(plistFile: plistFile, key: 'UIApplicationSupportsIndirectInputEvents');
expect(indirectInput, isTrue);
final String displayName = _getStringValueFromPlist(plistFile: plistFile, key: 'CFBundleDisplayName');
expect(displayName, 'My Project');
}, overrides: <Type, Generator>{
......@@ -1443,6 +1449,8 @@ void main() {
expect(plistFile, exists);
final bool disabled = _getBooleanValueFromPlist(plistFile: plistFile, key: 'CADisableMinimumFrameDurationOnPhone');
expect(disabled, isTrue);
final bool indirectInput = _getBooleanValueFromPlist(plistFile: plistFile, key: 'UIApplicationSupportsIndirectInputEvents');
expect(indirectInput, isTrue);
final String displayName = _getStringValueFromPlist(plistFile: plistFile, key: 'CFBundleDisplayName');
expect(displayName, 'My Project');
}, overrides: <Type, Generator>{
......@@ -1469,6 +1477,8 @@ void main() {
expect(plistFile, exists);
final bool disabled = _getBooleanValueFromPlist(plistFile: plistFile, key: 'CADisableMinimumFrameDurationOnPhone');
expect(disabled, isTrue);
final bool indirectInput = _getBooleanValueFromPlist(plistFile: plistFile, key: 'UIApplicationSupportsIndirectInputEvents');
expect(indirectInput, isTrue);
final String displayName = _getStringValueFromPlist(plistFile: plistFile, key: 'CFBundleDisplayName');
expect(displayName, 'My Project');
});
......@@ -1486,6 +1496,8 @@ void main() {
expect(plistFile, exists);
final bool disabled = _getBooleanValueFromPlist(plistFile: plistFile, key: 'CADisableMinimumFrameDurationOnPhone');
expect(disabled, isTrue);
final bool indirectInput = _getBooleanValueFromPlist(plistFile: plistFile, key: 'UIApplicationSupportsIndirectInputEvents');
expect(indirectInput, isTrue);
final String displayName = _getStringValueFromPlist(plistFile: plistFile, key: 'CFBundleDisplayName');
expect(displayName, 'My Project');
});
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment