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 =
......
......@@ -1986,6 +1986,354 @@ class _TransformedPointerScrollEvent extends _TransformedPointerEvent with _Copy
}
}
mixin _CopyPointerPanZoomStartEvent on PointerEvent {
@override
PointerPanZoomStartEvent copyWith({
Duration? timeStamp,
int? pointer,
PointerDeviceKind? kind,
int? device,
Offset? position,
Offset? delta,
int? buttons,
bool? obscured,
double? pressure,
double? pressureMin,
double? pressureMax,
double? distance,
double? distanceMax,
double? size,
double? radiusMajor,
double? radiusMinor,
double? radiusMin,
double? radiusMax,
double? orientation,
double? tilt,
bool? synthesized,
int? embedderId,
}) {
return PointerPanZoomStartEvent(
timeStamp: timeStamp ?? this.timeStamp,
kind: kind ?? this.kind,
device: device ?? this.device,
position: position ?? this.position,
embedderId: embedderId ?? this.embedderId,
).transformed(transform);
}
}
/// A pan/zoom has begun on this pointer.
///
/// See also:
///
/// * [Listener.onPointerPanZoomStart], which allows callers to be notified of these
/// events in a widget tree.
class PointerPanZoomStartEvent extends PointerEvent with _PointerEventDescription, _CopyPointerPanZoomStartEvent {
/// Creates a pointer pan/zoom start event.
///
/// All of the arguments must be non-null.
const PointerPanZoomStartEvent({
Duration timeStamp = Duration.zero,
PointerDeviceKind kind = PointerDeviceKind.mouse,
int device = 0,
int pointer = 0,
Offset position = Offset.zero,
int embedderId = 0,
bool synthesized = false,
}) : assert(timeStamp != null),
assert(kind != null),
assert(device != null),
assert(pointer != null),
assert(position != null),
assert(embedderId != null),
assert(synthesized != null),
super(
timeStamp: timeStamp,
kind: kind,
device: device,
pointer: pointer,
position: position,
embedderId: embedderId,
synthesized: synthesized,
);
@override
PointerPanZoomStartEvent transformed(Matrix4? transform) {
if (transform == null || transform == this.transform) {
return this;
}
return _TransformedPointerPanZoomStartEvent(original as PointerPanZoomStartEvent? ?? this, transform);
}
}
class _TransformedPointerPanZoomStartEvent extends _TransformedPointerEvent with _CopyPointerPanZoomStartEvent implements PointerPanZoomStartEvent {
_TransformedPointerPanZoomStartEvent(this.original, this.transform)
: assert(original != null), assert(transform != null);
@override
final PointerPanZoomStartEvent original;
@override
final Matrix4 transform;
@override
PointerPanZoomStartEvent transformed(Matrix4? transform) => original.transformed(transform);
}
mixin _CopyPointerPanZoomUpdateEvent on PointerEvent {
/// The total pan offset of the pan/zoom.
Offset get pan;
/// The total pan offset of the pan/zoom, transformed into local coordinates.
Offset get localPan;
/// The amount the pan offset changed since the last event.
Offset get panDelta;
/// The amount the pan offset changed since the last event, transformed into local coordinates.
Offset get localPanDelta;
/// The scale (zoom factor) of the pan/zoom.
double get scale;
/// The amount the pan/zoom has rotated in radians so far.
double get rotation;
@override
PointerPanZoomUpdateEvent copyWith({
Duration? timeStamp,
int? pointer,
PointerDeviceKind? kind,
int? device,
Offset? position,
Offset? delta,
int? buttons,
bool? obscured,
double? pressure,
double? pressureMin,
double? pressureMax,
double? distance,
double? distanceMax,
double? size,
double? radiusMajor,
double? radiusMinor,
double? radiusMin,
double? radiusMax,
double? orientation,
double? tilt,
bool? synthesized,
int? embedderId,
Offset? pan,
Offset? localPan,
Offset? panDelta,
Offset? localPanDelta,
double? scale,
double? rotation,
}) {
return PointerPanZoomUpdateEvent(
timeStamp: timeStamp ?? this.timeStamp,
kind: kind ?? this.kind,
device: device ?? this.device,
position: position ?? this.position,
embedderId: embedderId ?? this.embedderId,
pan: pan ?? this.pan,
panDelta: panDelta ?? this.panDelta,
scale: scale ?? this.scale,
rotation: rotation ?? this.rotation,
).transformed(transform);
}
}
/// The active pan/zoom on this pointer has updated.
///
/// See also:
///
/// * [Listener.onPointerPanZoomUpdate], which allows callers to be notified of these
/// events in a widget tree.
class PointerPanZoomUpdateEvent extends PointerEvent with _PointerEventDescription, _CopyPointerPanZoomUpdateEvent {
/// Creates a pointer pan/zoom update event.
///
/// All of the arguments must be non-null.
const PointerPanZoomUpdateEvent({
Duration timeStamp = Duration.zero,
PointerDeviceKind kind = PointerDeviceKind.mouse,
int device = 0,
int pointer = 0,
Offset position = Offset.zero,
int embedderId = 0,
this.pan = Offset.zero,
this.panDelta = Offset.zero,
this.scale = 1.0,
this.rotation = 0.0,
bool synthesized = false,
}) : assert(timeStamp != null),
assert(kind != null),
assert(device != null),
assert(pointer != null),
assert(position != null),
assert(embedderId != null),
assert(pan != null),
assert(panDelta != null),
assert(scale != null),
assert(rotation != null),
assert(synthesized != null),
super(
timeStamp: timeStamp,
kind: kind,
device: device,
pointer: pointer,
position: position,
embedderId: embedderId,
synthesized: synthesized,
);
@override
final Offset pan;
@override
Offset get localPan => pan;
@override
final Offset panDelta;
@override
Offset get localPanDelta => panDelta;
@override
final double scale;
@override
final double rotation;
@override
PointerPanZoomUpdateEvent transformed(Matrix4? transform) {
if (transform == null || transform == this.transform) {
return this;
}
return _TransformedPointerPanZoomUpdateEvent(original as PointerPanZoomUpdateEvent? ?? this, transform);
}
}
class _TransformedPointerPanZoomUpdateEvent extends _TransformedPointerEvent with _CopyPointerPanZoomUpdateEvent implements PointerPanZoomUpdateEvent {
_TransformedPointerPanZoomUpdateEvent(this.original, this.transform)
: assert(original != null), assert(transform != null);
@override
Offset get pan => original.pan;
@override
late final Offset localPan = PointerEvent.transformPosition(transform, pan);
@override
Offset get panDelta => original.panDelta;
@override
late final Offset localPanDelta = PointerEvent.transformDeltaViaPositions(
transform: transform,
untransformedDelta: panDelta,
untransformedEndPosition: pan,
transformedEndPosition: localPan,
);
@override
double get scale => original.scale;
@override
double get rotation => original.rotation;
@override
final PointerPanZoomUpdateEvent original;
@override
final Matrix4 transform;
@override
PointerPanZoomUpdateEvent transformed(Matrix4? transform) => original.transformed(transform);
}
mixin _CopyPointerPanZoomEndEvent on PointerEvent {
@override
PointerPanZoomEndEvent copyWith({
Duration? timeStamp,
int? pointer,
PointerDeviceKind? kind,
int? device,
Offset? position,
Offset? delta,
int? buttons,
bool? obscured,
double? pressure,
double? pressureMin,
double? pressureMax,
double? distance,
double? distanceMax,
double? size,
double? radiusMajor,
double? radiusMinor,
double? radiusMin,
double? radiusMax,
double? orientation,
double? tilt,
bool? synthesized,
int? embedderId,
}) {
return PointerPanZoomEndEvent(
timeStamp: timeStamp ?? this.timeStamp,
kind: kind ?? this.kind,
device: device ?? this.device,
position: position ?? this.position,
embedderId: embedderId ?? this.embedderId,
).transformed(transform);
}
}
/// The pan/zoom on this pointer has ended.
///
/// See also:
///
/// * [Listener.onPointerPanZoomEnd], which allows callers to be notified of these
/// events in a widget tree.
class PointerPanZoomEndEvent extends PointerEvent with _PointerEventDescription, _CopyPointerPanZoomEndEvent {
/// Creates a pointer pan/zoom end event.
///
/// All of the arguments must be non-null.
const PointerPanZoomEndEvent({
Duration timeStamp = Duration.zero,
PointerDeviceKind kind = PointerDeviceKind.mouse,
int device = 0,
int pointer = 0,
Offset position = Offset.zero,
int embedderId = 0,
bool synthesized = false,
}) : assert(timeStamp != null),
assert(kind != null),
assert(device != null),
assert(pointer != null),
assert(position != null),
assert(embedderId != null),
assert(synthesized != null),
super(
timeStamp: timeStamp,
kind: kind,
device: device,
pointer: pointer,
position: position,
embedderId: embedderId,
synthesized: synthesized,
);
@override
PointerPanZoomEndEvent transformed(Matrix4? transform) {
if (transform == null || transform == this.transform) {
return this;
}
return _TransformedPointerPanZoomEndEvent(original as PointerPanZoomEndEvent? ?? this, transform);
}
}
class _TransformedPointerPanZoomEndEvent extends _TransformedPointerEvent with _CopyPointerPanZoomEndEvent implements PointerPanZoomEndEvent {
_TransformedPointerPanZoomEndEvent(this.original, this.transform)
: assert(original != null), assert(transform != null);
@override
final PointerPanZoomEndEvent original;
@override
final Matrix4 transform;
@override
PointerPanZoomEndEvent transformed(Matrix4? transform) => original.transformed(transform);
}
mixin _CopyPointerCancelEvent on PointerEvent {
@override
PointerCancelEvent copyWith({
......@@ -2108,8 +2456,7 @@ double computeHitSlop(PointerDeviceKind kind, DeviceGestureSettings? settings) {
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
case PointerDeviceKind.trackpad:
return settings?.touchSlop ?? kTouchSlop;
}
}
......@@ -2123,8 +2470,7 @@ double computePanSlop(PointerDeviceKind kind, DeviceGestureSettings? settings) {
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
case PointerDeviceKind.trackpad:
return settings?.panSlop ?? kPanSlop;
}
}
......@@ -2138,8 +2484,7 @@ double computeScaleSlop(PointerDeviceKind kind) {
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
case PointerDeviceKind.trackpad:
return kScaleSlop;
}
}
......
......@@ -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);
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) {
if (event.buttons != _initialButtons) {
}
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);
}
}
......
......@@ -31,6 +31,20 @@ enum _ScaleState {
started,
}
class _PointerPanZoomData {
_PointerPanZoomData({
required this.focalPoint,
required this.scale,
required this.rotation
});
Offset focalPoint;
double scale;
double rotation;
@override
String toString() => '_PointerPanZoomData(focalPoint: $focalPoint, scale: $scale, angle: $rotation)';
}
/// Details for [GestureScaleStartCallback].
class ScaleStartDetails {
/// Creates details for [GestureScaleStartCallback].
......@@ -175,7 +189,7 @@ class ScaleUpdateDetails {
' verticalScale: $verticalScale,'
' rotation: $rotation,'
' pointerCount: $pointerCount,'
' focalPointDelta: $localFocalPoint)';
' focalPointDelta: $focalPointDelta)';
}
/// Details for [GestureScaleEndCallback].
......@@ -329,21 +343,51 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
late Offset _localFocalPoint;
_LineBetweenPointers? _initialLine;
_LineBetweenPointers? _currentLine;
late Map<int, Offset> _pointerLocations;
late List<int> _pointerQueue; // A queue to sort pointers in order of entrance
final Map<int, Offset> _pointerLocations = <int, Offset>{};
final List<int> _pointerQueue = <int>[]; // A queue to sort pointers in order of entrance
final Map<int, VelocityTracker> _velocityTrackers = <int, VelocityTracker>{};
late Offset _delta;
final Map<int, _PointerPanZoomData> _pointerPanZooms = <int, _PointerPanZoomData>{};
double _initialPanZoomScaleFactor = 1;
double _initialPanZoomRotationFactor = 0;
double get _scaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0;
double get _pointerScaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0;
double get _horizontalScaleFactor => _initialHorizontalSpan > 0.0 ? _currentHorizontalSpan / _initialHorizontalSpan : 1.0;
double get _pointerHorizontalScaleFactor => _initialHorizontalSpan > 0.0 ? _currentHorizontalSpan / _initialHorizontalSpan : 1.0;
double get _verticalScaleFactor => _initialVerticalSpan > 0.0 ? _currentVerticalSpan / _initialVerticalSpan : 1.0;
double get _pointerVerticalScaleFactor => _initialVerticalSpan > 0.0 ? _currentVerticalSpan / _initialVerticalSpan : 1.0;
double _computeRotationFactor() {
if (_initialLine == null || _currentLine == null) {
return 0.0;
double get _scaleFactor {
double scale = _pointerScaleFactor;
for (final _PointerPanZoomData p in _pointerPanZooms.values) {
scale *= p.scale / _initialPanZoomScaleFactor;
}
return scale;
}
double get _horizontalScaleFactor {
double scale = _pointerHorizontalScaleFactor;
for (final _PointerPanZoomData p in _pointerPanZooms.values) {
scale *= p.scale / _initialPanZoomScaleFactor;
}
return scale;
}
double get _verticalScaleFactor {
double scale = _pointerVerticalScaleFactor;
for (final _PointerPanZoomData p in _pointerPanZooms.values) {
scale *= p.scale / _initialPanZoomScaleFactor;
}
return scale;
}
int get _pointerCount {
return _pointerPanZooms.length + _pointerQueue.length;
}
double _computeRotationFactor() {
double factor = 0.0;
if (_initialLine != null && _currentLine != null) {
final double fx = _initialLine!.pointerStartLocation.dx;
final double fy = _initialLine!.pointerStartLocation.dy;
final double sx = _initialLine!.pointerEndLocation.dx;
......@@ -357,7 +401,13 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
final double angle1 = math.atan2(fy - sy, fx - sx);
final double angle2 = math.atan2(nfy - nsy, nfx - nsx);
return angle2 - angle1;
factor = angle2 - angle1;
}
for (final _PointerPanZoomData p in _pointerPanZooms.values) {
factor += p.rotation;
}
factor -= _initialPanZoomRotationFactor;
return factor;
}
@override
......@@ -372,8 +422,21 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
_currentHorizontalSpan = 0.0;
_initialVerticalSpan = 0.0;
_currentVerticalSpan = 0.0;
_pointerLocations = <int, Offset>{};
_pointerQueue = <int>[];
}
}
@override
bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) => true;
@override
void addAllowedPointerPanZoom(PointerPanZoomStartEvent event) {
super.addAllowedPointerPanZoom(event);
startTrackingPointer(event.pointer, event.transform);
_velocityTrackers[event.pointer] = VelocityTracker.withKind(event.kind);
if (_state == _ScaleState.ready) {
_state = _ScaleState.possible;
_initialPanZoomScaleFactor = 1.0;
_initialPanZoomRotationFactor = 0.0;
}
}
......@@ -400,6 +463,30 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
_pointerQueue.remove(event.pointer);
didChangeConfiguration = true;
_lastTransform = event.transform;
} else if (event is PointerPanZoomStartEvent) {
assert(_pointerPanZooms[event.pointer] == null);
_pointerPanZooms[event.pointer] = _PointerPanZoomData(
focalPoint: event.position,
scale: 1,
rotation: 0
);
didChangeConfiguration = true;
shouldStartIfAccepted = true;
} else if (event is PointerPanZoomUpdateEvent) {
assert(_pointerPanZooms[event.pointer] != null);
if (!event.synthesized)
_velocityTrackers[event.pointer]!.addPosition(event.timeStamp, event.pan);
_pointerPanZooms[event.pointer] = _PointerPanZoomData(
focalPoint: event.position + event.pan,
scale: event.scale,
rotation: event.rotation
);
_lastTransform = event.transform;
shouldStartIfAccepted = true;
} else if (event is PointerPanZoomEndEvent) {
assert(_pointerPanZooms[event.pointer] != null);
_pointerPanZooms.remove(event.pointer);
didChangeConfiguration = true;
}
_updateLines();
......@@ -411,15 +498,15 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
}
void _update() {
final int count = _pointerLocations.keys.length;
final Offset? previousFocalPoint = _currentFocalPoint;
// Compute the focal point
Offset focalPoint = Offset.zero;
for (final int pointer in _pointerLocations.keys)
focalPoint += _pointerLocations[pointer]!;
_currentFocalPoint = count > 0 ? focalPoint / count.toDouble() : Offset.zero;
for (final _PointerPanZoomData p in _pointerPanZooms.values)
focalPoint += p.focalPoint;
_currentFocalPoint = _pointerCount > 0 ? focalPoint / _pointerCount.toDouble() : Offset.zero;
if (previousFocalPoint == null) {
_localFocalPoint = PointerEvent.transformPosition(
......@@ -436,6 +523,14 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
_delta = _localFocalPoint - localPreviousFocalPoint;
}
final int count = _pointerLocations.keys.length;
Offset pointerFocalPoint = Offset.zero;
for (final int pointer in _pointerLocations.keys)
pointerFocalPoint += _pointerLocations[pointer]!;
if (count > 0)
pointerFocalPoint = pointerFocalPoint / count.toDouble();
// Span is the average deviation from focal point. Horizontal and vertical
// spans are the average deviations from the focal point's horizontal and
// vertical coordinates, respectively.
......@@ -443,9 +538,9 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
double totalHorizontalDeviation = 0.0;
double totalVerticalDeviation = 0.0;
for (final int pointer in _pointerLocations.keys) {
totalDeviation += (_currentFocalPoint! - _pointerLocations[pointer]!).distance;
totalHorizontalDeviation += (_currentFocalPoint!.dx - _pointerLocations[pointer]!.dx).abs();
totalVerticalDeviation += (_currentFocalPoint!.dy - _pointerLocations[pointer]!.dy).abs();
totalDeviation += (pointerFocalPoint - _pointerLocations[pointer]!).distance;
totalHorizontalDeviation += (pointerFocalPoint.dx - _pointerLocations[pointer]!.dx).abs();
totalVerticalDeviation += (pointerFocalPoint.dy - _pointerLocations[pointer]!.dy).abs();
}
_currentSpan = count > 0 ? totalDeviation / count : 0.0;
_currentHorizontalSpan = count > 0 ? totalHorizontalDeviation / count : 0.0;
......@@ -488,6 +583,13 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
_initialLine = _currentLine;
_initialHorizontalSpan = _currentHorizontalSpan;
_initialVerticalSpan = _currentVerticalSpan;
if (_pointerPanZooms.isEmpty) {
_initialPanZoomScaleFactor = 1.0;
_initialPanZoomRotationFactor = 0.0;
} else {
_initialPanZoomScaleFactor = _scaleFactor / _pointerScaleFactor;
_initialPanZoomRotationFactor = _pointerPanZooms.values.map((_PointerPanZoomData x) => x.rotation).reduce((double a, double b) => a + b);
}
if (_state == _ScaleState.started) {
if (onEnd != null) {
final VelocityTracker tracker = _velocityTrackers[pointer]!;
......@@ -497,9 +599,9 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
final Offset pixelsPerSecond = velocity.pixelsPerSecond;
if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity)
velocity = Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity);
invokeCallback<void>('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerQueue.length)));
invokeCallback<void>('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerCount)));
} else {
invokeCallback<void>('onEnd', () => onEnd!(ScaleEndDetails(pointerCount: _pointerQueue.length)));
invokeCallback<void>('onEnd', () => onEnd!(ScaleEndDetails(pointerCount: _pointerCount)));
}
}
_state = _ScaleState.accepted;
......@@ -515,7 +617,7 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
if (_state == _ScaleState.possible) {
final double spanDelta = (_currentSpan - _initialSpan).abs();
final double focalPointDelta = (_currentFocalPoint! - _initialFocalPoint).distance;
if (spanDelta > computeScaleSlop(pointerDeviceKind) || focalPointDelta > computePanSlop(pointerDeviceKind, gestureSettings))
if (spanDelta > computeScaleSlop(pointerDeviceKind) || focalPointDelta > computePanSlop(pointerDeviceKind, gestureSettings) || math.max(_scaleFactor / _pointerScaleFactor, _pointerScaleFactor / _scaleFactor) > 1.05)
resolve(GestureDisposition.accepted);
} else if (_state.index >= _ScaleState.accepted.index) {
resolve(GestureDisposition.accepted);
......@@ -535,7 +637,7 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
focalPoint: _currentFocalPoint!,
localFocalPoint: _localFocalPoint,
rotation: _computeRotationFactor(),
pointerCount: _pointerQueue.length,
pointerCount: _pointerCount,
focalPointDelta: _delta,
));
});
......@@ -548,7 +650,7 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
onStart!(ScaleStartDetails(
focalPoint: _currentFocalPoint!,
localFocalPoint: _localFocalPoint,
pointerCount: _pointerQueue.length,
pointerCount: _pointerCount,
));
});
}
......@@ -564,12 +666,22 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer {
_initialLine = _currentLine;
_initialHorizontalSpan = _currentHorizontalSpan;
_initialVerticalSpan = _currentVerticalSpan;
if (_pointerPanZooms.isEmpty) {
_initialPanZoomScaleFactor = 1.0;
_initialPanZoomRotationFactor = 0.0;
} else {
_initialPanZoomScaleFactor = _scaleFactor / _pointerScaleFactor;
_initialPanZoomRotationFactor = _pointerPanZooms.values.map((_PointerPanZoomData x) => x.rotation).reduce((double a, double b) => a + b);
}
}
}
}
@override
void rejectGesture(int pointer) {
_pointerPanZooms.remove(pointer);
_pointerLocations.remove(pointer);
_pointerQueue.remove(pointer);
stopTrackingPointer(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>());
});
}
......@@ -370,9 +370,9 @@ void main() {
tester.route(down);
expect(log, isEmpty);
// scale will win if focal point delta exceeds 18.0*2
// Scale will win if focal point delta exceeds 18.0*2.
tester.route(pointer1.move(const Offset(10.0, 50.0))); // delta of 40.0 exceeds 18.0*2
tester.route(pointer1.move(const Offset(10.0, 50.0))); // Delta of 40.0 exceeds 18.0*2.
expect(log, equals(<String>['scale-start', 'scale-update']));
log.clear();
......@@ -704,6 +704,461 @@ void main() {
scale.dispose();
});
testGesture('Should recognize scale gestures from pointer pan/zoom events', (GestureTester tester) {
final ScaleGestureRecognizer scale = ScaleGestureRecognizer();
final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer();
bool didStartScale = false;
Offset? updatedFocalPoint;
scale.onStart = (ScaleStartDetails details) {
didStartScale = true;
updatedFocalPoint = details.focalPoint;
};
double? updatedScale;
double? updatedHorizontalScale;
double? updatedVerticalScale;
Offset? updatedDelta;
scale.onUpdate = (ScaleUpdateDetails details) {
updatedScale = details.scale;
updatedHorizontalScale = details.horizontalScale;
updatedVerticalScale = details.verticalScale;
updatedFocalPoint = details.focalPoint;
updatedDelta = details.focalPointDelta;
};
bool didEndScale = false;
scale.onEnd = (ScaleEndDetails details) {
didEndScale = true;
};
final TestPointer pointer1 = TestPointer(2);
final PointerPanZoomStartEvent start = pointer1.panZoomStart(Offset.zero);
scale.addPointerPanZoom(start);
drag.addPointerPanZoom(start);
tester.closeArena(2);
expect(didStartScale, isFalse);
expect(updatedScale, isNull);
expect(updatedFocalPoint, isNull);
expect(updatedDelta, isNull);
expect(didEndScale, isFalse);
// Panning.
tester.route(start);
expect(didStartScale, isFalse);
expect(updatedScale, isNull);
expect(updatedFocalPoint, isNull);
expect(updatedDelta, isNull);
expect(didEndScale, isFalse);
tester.route(pointer1.panZoomUpdate(Offset.zero, pan: const Offset(20.0, 30.0)));
expect(didStartScale, isTrue);
didStartScale = false;
expect(updatedFocalPoint, const Offset(20.0, 30.0));
updatedFocalPoint = null;
expect(updatedScale, 1.0);
updatedScale = null;
expect(updatedDelta, const Offset(20.0, 30.0));
updatedDelta = null;
expect(didEndScale, isFalse);
// Zoom in.
tester.route(pointer1.panZoomUpdate(Offset.zero, pan: const Offset(20.0, 30.0), scale: 2.0));
expect(updatedFocalPoint, const Offset(20.0, 30.0));
updatedFocalPoint = null;
expect(updatedScale, 2.0);
expect(updatedHorizontalScale, 2.0);
expect(updatedVerticalScale, 2.0);
expect(updatedDelta, Offset.zero);
updatedScale = null;
updatedHorizontalScale = null;
updatedVerticalScale = null;
updatedDelta = null;
expect(didEndScale, isFalse);
// Zoom out.
tester.route(pointer1.panZoomUpdate(Offset.zero, pan: const Offset(20.0, 30.0)));
expect(updatedFocalPoint, const Offset(20.0, 30.0));
updatedFocalPoint = null;
expect(updatedScale, 1.0);
expect(updatedHorizontalScale, 1.0);
expect(updatedVerticalScale, 1.0);
expect(updatedDelta, Offset.zero);
updatedScale = null;
updatedHorizontalScale = null;
updatedVerticalScale = null;
updatedDelta = null;
expect(didEndScale, isFalse);
// We are done.
tester.route(pointer1.panZoomEnd());
expect(didStartScale, isFalse);
expect(updatedFocalPoint, isNull);
expect(updatedScale, isNull);
expect(updatedDelta, isNull);
expect(didEndScale, isTrue);
didEndScale = false;
scale.dispose();
});
testGesture('Pointer pan/zooms should work alongside touches', (GestureTester tester) {
final ScaleGestureRecognizer scale = ScaleGestureRecognizer();
final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer();
bool didStartScale = false;
Offset? updatedFocalPoint;
scale.onStart = (ScaleStartDetails details) {
didStartScale = true;
updatedFocalPoint = details.focalPoint;
};
double? updatedScale;
double? updatedHorizontalScale;
double? updatedVerticalScale;
Offset? updatedDelta;
double? updatedRotation;
scale.onUpdate = (ScaleUpdateDetails details) {
updatedScale = details.scale;
updatedHorizontalScale = details.horizontalScale;
updatedVerticalScale = details.verticalScale;
updatedFocalPoint = details.focalPoint;
updatedDelta = details.focalPointDelta;
updatedRotation = details.rotation;
};
bool didEndScale = false;
scale.onEnd = (ScaleEndDetails details) {
didEndScale = true;
};
final TestPointer touchPointer1 = TestPointer(2);
final TestPointer touchPointer2 = TestPointer(3);
final TestPointer panZoomPointer = TestPointer(4);
final PointerPanZoomStartEvent panZoomStart = panZoomPointer.panZoomStart(Offset.zero);
scale.addPointerPanZoom(panZoomStart);
drag.addPointerPanZoom(panZoomStart);
tester.closeArena(4);
expect(didStartScale, isFalse);
expect(updatedScale, isNull);
expect(updatedFocalPoint, isNull);
expect(updatedDelta, isNull);
expect(didEndScale, isFalse);
// Panning starting with trackpad.
tester.route(panZoomStart);
expect(didStartScale, isFalse);
expect(updatedScale, isNull);
expect(updatedFocalPoint, isNull);
expect(updatedDelta, isNull);
expect(didEndScale, isFalse);
tester.route(panZoomPointer.panZoomUpdate(Offset.zero, pan: const Offset(40, 40)));
expect(didStartScale, isTrue);
didStartScale = false;
expect(updatedFocalPoint, const Offset(40.0, 40.0));
updatedFocalPoint = null;
expect(updatedScale, 1.0);
updatedScale = null;
expect(updatedDelta, const Offset(40.0, 40.0));
updatedDelta = null;
expect(didEndScale, isFalse);
// Add a touch pointer.
final PointerDownEvent touchStart1 = touchPointer1.down(const Offset(40, 40));
scale.addPointer(touchStart1);
drag.addPointer(touchStart1);
tester.closeArena(2);
tester.route(touchStart1);
expect(didEndScale, isTrue);
didEndScale = false;
tester.route(touchPointer1.move(const Offset(10, 10)));
expect(didStartScale, isTrue);
didStartScale = false;
expect(updatedFocalPoint, const Offset(25, 25));
updatedFocalPoint = null;
// 1 down pointer + pointer pan/zoom should not scale, only pan.
expect(updatedScale, 1.0);
updatedScale = null;
expect(updatedDelta, const Offset(-15, -15));
updatedDelta = null;
expect(didEndScale, isFalse);
// Add a second touch pointer.
final PointerDownEvent touchStart2 = touchPointer2.down(const Offset(10, 40));
scale.addPointer(touchStart2);
drag.addPointer(touchStart2);
tester.closeArena(3);
tester.route(touchStart2);
expect(didEndScale, isTrue);
didEndScale = false;
// Move the second pointer to cause pan, zoom, and rotation.
tester.route(touchPointer2.move(const Offset(40, 40)));
expect(didStartScale, isTrue);
didStartScale = false;
expect(updatedFocalPoint, const Offset(30, 30));
updatedFocalPoint = null;
expect(updatedScale, math.sqrt(2));
updatedScale = null;
expect(updatedHorizontalScale, 1.0);
updatedHorizontalScale = null;
expect(updatedVerticalScale, 1.0);
updatedVerticalScale = null;
expect(updatedDelta, const Offset(10, 0));
updatedDelta = null;
expect(updatedRotation, -math.pi / 4);
updatedRotation = null;
expect(didEndScale, isFalse);
// Change the scale and angle of the pan/zoom to test combining.
// Scale should be multiplied together.
// Rotation angle should be added together.
tester.route(panZoomPointer.panZoomUpdate(Offset.zero, pan: const Offset(40, 40), scale: math.sqrt(2), rotation: math.pi / 3));
expect(didStartScale, isFalse);
expect(updatedFocalPoint, const Offset(30, 30));
updatedFocalPoint = null;
expect(updatedScale, closeTo(2, 0.0001));
updatedScale = null;
expect(updatedHorizontalScale, math.sqrt(2));
updatedHorizontalScale = null;
expect(updatedVerticalScale, math.sqrt(2));
updatedVerticalScale = null;
expect(updatedDelta, Offset.zero);
updatedDelta = null;
expect(updatedRotation, closeTo(math.pi / 12, 0.0001));
updatedRotation = null;
expect(didEndScale, isFalse);
// Move the pan/zoom origin to test combining.
tester.route(panZoomPointer.panZoomUpdate(const Offset(15, 15), pan: const Offset(55, 55), scale: math.sqrt(2), rotation: math.pi / 3));
expect(didStartScale, isFalse);
expect(updatedFocalPoint, const Offset(40, 40));
updatedFocalPoint = null;
expect(updatedScale, closeTo(2, 0.0001));
updatedScale = null;
expect(updatedDelta, const Offset(10, 10));
updatedDelta = null;
expect(updatedRotation, closeTo(math.pi / 12, 0.0001));
updatedRotation = null;
expect(didEndScale, isFalse);
// We are done.
tester.route(panZoomPointer.panZoomEnd());
expect(updatedFocalPoint, isNull);
expect(didEndScale, isTrue);
didEndScale = false;
expect(updatedScale, isNull);
expect(updatedDelta, isNull);
expect(didStartScale, isFalse);
tester.route(touchPointer1.up());
expect(updatedFocalPoint, isNull);
expect(didEndScale, isFalse);
expect(updatedScale, isNull);
expect(updatedDelta, isNull);
expect(didStartScale, isFalse);
tester.route(touchPointer2.up());
expect(didEndScale, isFalse);
expect(updatedFocalPoint, isNull);
expect(updatedScale, isNull);
expect(updatedDelta, isNull);
expect(didStartScale, isFalse);
scale.dispose();
});
testGesture('Scale gesture competes with drag for trackpad gesture', (GestureTester tester) {
final ScaleGestureRecognizer scale = ScaleGestureRecognizer();
final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer();
final List<String> log = <String>[];
scale.onStart = (ScaleStartDetails details) { log.add('scale-start'); };
scale.onUpdate = (ScaleUpdateDetails details) { log.add('scale-update'); };
scale.onEnd = (ScaleEndDetails details) { log.add('scale-end'); };
drag.onStart = (DragStartDetails details) { log.add('drag-start'); };
drag.onEnd = (DragEndDetails details) { log.add('drag-end'); };
final TestPointer pointer1 = TestPointer(2);
final PointerPanZoomStartEvent down = pointer1.panZoomStart(const Offset(10.0, 10.0));
scale.addPointerPanZoom(down);
drag.addPointerPanZoom(down);
tester.closeArena(2);
expect(log, isEmpty);
// Vertical moves are scales.
tester.route(down);
expect(log, isEmpty);
// Scale will win if focal point delta exceeds 18.0*2.
tester.route(pointer1.panZoomUpdate(const Offset(10.0, 10.0), pan: const Offset(10.0, 40.0))); // delta of 40.0 exceeds 18.0*2.
expect(log, equals(<String>['scale-start', 'scale-update']));
log.clear();
final TestPointer pointer2 = TestPointer(3);
final PointerPanZoomStartEvent down2 = pointer2.panZoomStart(const Offset(10.0, 20.0));
scale.addPointerPanZoom(down2);
drag.addPointerPanZoom(down2);
tester.closeArena(3);
expect(log, isEmpty);
// Second pointer joins scale even though it moves horizontally.
tester.route(down2);
expect(log, <String>['scale-end']);
log.clear();
tester.route(pointer2.panZoomUpdate(const Offset(10.0, 20.0), pan: const Offset(20.0, 0.0)));
expect(log, equals(<String>['scale-start', 'scale-update']));
log.clear();
tester.route(pointer1.panZoomEnd());
expect(log, equals(<String>['scale-end']));
log.clear();
tester.route(pointer2.panZoomEnd());
expect(log, isEmpty);
log.clear();
// Horizontal moves are either drags or scales, depending on which wins first.
// TODO(ianh): https://github.com/flutter/flutter/issues/11384
// In this case, we move fast, so that the scale wins. If we moved slowly,
// the horizontal drag would win, since it was added first.
final TestPointer pointer3 = TestPointer(4);
final PointerPanZoomStartEvent down3 = pointer3.panZoomStart(const Offset(30.0, 30.0));
scale.addPointerPanZoom(down3);
drag.addPointerPanZoom(down3);
tester.closeArena(4);
tester.route(down3);
expect(log, isEmpty);
tester.route(pointer3.panZoomUpdate(const Offset(30.0, 30.0), pan: const Offset(70.0, 0.0)));
expect(log, equals(<String>['scale-start', 'scale-update']));
log.clear();
tester.route(pointer3.panZoomEnd());
expect(log, equals(<String>['scale-end']));
log.clear();
scale.dispose();
drag.dispose();
});
testGesture('Scale gesture from pan/zoom events properly handles DragStartBehavior.start', (GestureTester tester) {
final ScaleGestureRecognizer scale = ScaleGestureRecognizer(dragStartBehavior: DragStartBehavior.start);
final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer();
bool didStartScale = false;
Offset? updatedFocalPoint;
scale.onStart = (ScaleStartDetails details) {
didStartScale = true;
updatedFocalPoint = details.focalPoint;
};
double? updatedScale;
double? updatedHorizontalScale;
double? updatedVerticalScale;
double? updatedRotation;
Offset? updatedDelta;
scale.onUpdate = (ScaleUpdateDetails details) {
updatedScale = details.scale;
updatedHorizontalScale = details.horizontalScale;
updatedVerticalScale = details.verticalScale;
updatedFocalPoint = details.focalPoint;
updatedRotation = details.rotation;
updatedDelta = details.focalPointDelta;
};
bool didEndScale = false;
scale.onEnd = (ScaleEndDetails details) {
didEndScale = true;
};
final TestPointer pointer1 = TestPointer(2);
final PointerPanZoomStartEvent start = pointer1.panZoomStart(Offset.zero);
scale.addPointerPanZoom(start);
drag.addPointerPanZoom(start);
tester.closeArena(2);
expect(didStartScale, isFalse);
expect(updatedScale, isNull);
expect(updatedFocalPoint, isNull);
expect(updatedDelta, isNull);
expect(didEndScale, isFalse);
tester.route(start);
expect(didStartScale, isFalse);
expect(updatedScale, isNull);
expect(updatedFocalPoint, isNull);
expect(updatedDelta, isNull);
expect(didEndScale, isFalse);
// Zoom enough to win the gesture.
tester.route(pointer1.panZoomUpdate(Offset.zero, scale: 1.1, rotation: 1));
expect(didStartScale, isTrue);
didStartScale = false;
expect(updatedFocalPoint, Offset.zero);
updatedFocalPoint = null;
expect(updatedScale, 1.0);
updatedScale = null;
expect(updatedDelta, Offset.zero);
updatedDelta = null;
expect(didEndScale, isFalse);
// Zoom in - should be relative to 1.1.
tester.route(pointer1.panZoomUpdate(Offset.zero, scale: 1.21, rotation: 1.5));
expect(updatedFocalPoint, Offset.zero);
updatedFocalPoint = null;
expect(updatedScale, closeTo(1.1, 0.0001));
expect(updatedHorizontalScale, closeTo(1.1, 0.0001));
expect(updatedVerticalScale, closeTo(1.1, 0.0001));
expect(updatedRotation, 0.5);
expect(updatedDelta, Offset.zero);
updatedScale = null;
updatedHorizontalScale = null;
updatedVerticalScale = null;
updatedRotation = null;
updatedDelta = null;
expect(didEndScale, isFalse);
// Zoom out - should be relative to 1.1.
tester.route(pointer1.panZoomUpdate(Offset.zero, scale: 0.99, rotation: 1.0));
expect(updatedFocalPoint, Offset.zero);
updatedFocalPoint = null;
expect(updatedScale, closeTo(0.9, 0.0001));
expect(updatedHorizontalScale, closeTo(0.9, 0.0001));
expect(updatedVerticalScale, closeTo(0.9, 0.0001));
expect(updatedRotation, 0.0);
expect(updatedDelta, Offset.zero);
updatedScale = null;
updatedHorizontalScale = null;
updatedVerticalScale = null;
updatedDelta = null;
expect(didEndScale, isFalse);
// We are done.
tester.route(pointer1.panZoomEnd());
expect(didStartScale, isFalse);
expect(updatedFocalPoint, isNull);
expect(updatedScale, isNull);
expect(updatedDelta, isNull);
expect(didEndScale, isTrue);
didEndScale = false;
scale.dispose();
});
testWidgets('ScaleGestureRecognizer asserts when kind and supportedDevices are both set', (WidgetTester tester) async {
expect(
() {
......
......@@ -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