diff --git a/packages/flutter/lib/src/gestures/binding.dart b/packages/flutter/lib/src/gestures/binding.dart index 784a35b792..a65eae1d7b 100644 --- a/packages/flutter/lib/src/gestures/binding.dart +++ b/packages/flutter/lib/src/gestures/binding.dart @@ -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); diff --git a/packages/flutter/lib/src/gestures/converter.dart b/packages/flutter/lib/src/gestures/converter.dart index 0cae6b1cbf..fea707a458 100644 --- a/packages/flutter/lib/src/gestures/converter.dart +++ b/packages/flutter/lib/src/gestures/converter.dart @@ -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 = diff --git a/packages/flutter/lib/src/gestures/events.dart b/packages/flutter/lib/src/gestures/events.dart index 3af55bae98..0131f491a3 100644 --- a/packages/flutter/lib/src/gestures/events.dart +++ b/packages/flutter/lib/src/gestures/events.dart @@ -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; } } diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart index 6385e881ad..e930f9e59e 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart @@ -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); } } diff --git a/packages/flutter/lib/src/gestures/recognizer.dart b/packages/flutter/lib/src/gestures/recognizer.dart index 0043630214..7e156defe4 100644 --- a/packages/flutter/lib/src/gestures/recognizer.dart +++ b/packages/flutter/lib/src/gestures/recognizer.dart @@ -96,6 +96,43 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT /// coming from. final Map _pointerToKind = {}; + /// 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); } } diff --git a/packages/flutter/lib/src/gestures/scale.dart b/packages/flutter/lib/src/gestures/scale.dart index 1671c81c48..4158974d64 100644 --- a/packages/flutter/lib/src/gestures/scale.dart +++ b/packages/flutter/lib/src/gestures/scale.dart @@ -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,35 +343,71 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { late Offset _localFocalPoint; _LineBetweenPointers? _initialLine; _LineBetweenPointers? _currentLine; - late Map _pointerLocations; - late List _pointerQueue; // A queue to sort pointers in order of entrance + final Map _pointerLocations = {}; + final List _pointerQueue = []; // A queue to sort pointers in order of entrance final Map _velocityTrackers = {}; late Offset _delta; + final Map _pointerPanZooms = {}; + 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 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() { - if (_initialLine == null || _currentLine == null) { - return 0.0; + double factor = 0.0; + if (_initialLine != null && _currentLine != null) { + final double fx = _initialLine!.pointerStartLocation.dx; + final double fy = _initialLine!.pointerStartLocation.dy; + final double sx = _initialLine!.pointerEndLocation.dx; + final double sy = _initialLine!.pointerEndLocation.dy; + + final double nfx = _currentLine!.pointerStartLocation.dx; + final double nfy = _currentLine!.pointerStartLocation.dy; + final double nsx = _currentLine!.pointerEndLocation.dx; + final double nsy = _currentLine!.pointerEndLocation.dy; + + final double angle1 = math.atan2(fy - sy, fx - sx); + final double angle2 = math.atan2(nfy - nsy, nfx - nsx); + + factor = angle2 - angle1; } - final double fx = _initialLine!.pointerStartLocation.dx; - final double fy = _initialLine!.pointerStartLocation.dy; - final double sx = _initialLine!.pointerEndLocation.dx; - final double sy = _initialLine!.pointerEndLocation.dy; - - final double nfx = _currentLine!.pointerStartLocation.dx; - final double nfy = _currentLine!.pointerStartLocation.dy; - final double nsx = _currentLine!.pointerEndLocation.dx; - final double nsy = _currentLine!.pointerEndLocation.dy; - - final double angle1 = math.atan2(fy - sy, fx - sx); - final double angle2 = math.atan2(nfy - nsy, nfx - nsx); - - return 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 = {}; - _pointerQueue = []; + } + } + + @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('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerQueue.length))); + invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerCount))); } else { - invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(pointerCount: _pointerQueue.length))); + invokeCallback('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); } diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 9b7b079c3e..fda022f69d 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -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: '', diff --git a/packages/flutter/lib/src/services/platform_views.dart b/packages/flutter/lib/src/services/platform_views.dart index 2cdcb5eccf..0cac3f2667 100644 --- a/packages/flutter/lib/src/services/platform_views.dart +++ b/packages/flutter/lib/src/services/platform_views.dart @@ -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; } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index d7166272ec..6e20274671 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -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('listeners', listeners, ifEmpty: '')); diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index 71e49045ac..4df4a70c42 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -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; diff --git a/packages/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index 1483b814cf..78cdacd9de 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -1435,6 +1435,13 @@ class RawGestureDetectorState extends State { 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 { Widget build(BuildContext context) { Widget result = Listener( onPointerDown: _handlePointerDown, + onPointerPanZoomStart: _handlePointerPanZoomStart, behavior: widget.behavior ?? _defaultBehavior, child: widget.child, ); diff --git a/packages/flutter/lib/src/widgets/scroll_configuration.dart b/packages/flutter/lib/src/widgets/scroll_configuration.dart index 947efaac38..97100f645a 100644 --- a/packages/flutter/lib/src/widgets/scroll_configuration.dart +++ b/packages/flutter/lib/src/widgets/scroll_configuration.dart @@ -19,6 +19,7 @@ const Set _kTouchLikeDeviceTypes = { PointerDeviceKind.touch, PointerDeviceKind.stylus, PointerDeviceKind.invertedStylus, + PointerDeviceKind.trackpad, // The VoiceAccess sends pointer events with unknown type when scrolling // scrollables. PointerDeviceKind.unknown, diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 03d514bbf5..93568aff10 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -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 extends State 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 extends State 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 extends State 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; } }, diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 1cf619f5f8..2fd7021d97 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -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); diff --git a/packages/flutter/test/gestures/drag_test.dart b/packages/flutter/test/gestures/drag_test.dart index 75a9e4df14..c6ef908a81 100644 --- a/packages/flutter/test/gestures/drag_test.dart +++ b/packages/flutter/test/gestures/drag_test.dart @@ -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; + }); } diff --git a/packages/flutter/test/gestures/events_test.dart b/packages/flutter/test/gestures/events_test.dart index b1959602d7..8d334fdf1b 100644 --- a/packages/flutter/test/gestures/events_test.dart +++ b/packages/flutter/test/gestures/events_test.dart @@ -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, diff --git a/packages/flutter/test/gestures/gesture_binding_test.dart b/packages/flutter/test/gestures/gesture_binding_test.dart index a651c5b6c4..0cfe66ac60 100644 --- a/packages/flutter/test/gestures/gesture_binding_test.dart +++ b/packages/flutter/test/gestures/gesture_binding_test.dart @@ -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(change: ui.PointerChange.panZoomStart), + ui.PointerData(change: ui.PointerChange.panZoomUpdate), + ui.PointerData(change: ui.PointerChange.panZoomEnd), + ], + ); + + final List events = []; + binding.callback = events.add; + + ui.window.onPointerDataPacket?.call(packet); + expect(events.length, 3); + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + }); } diff --git a/packages/flutter/test/gestures/scale_test.dart b/packages/flutter/test/gestures/scale_test.dart index fe333cd05c..e02823a39d 100644 --- a/packages/flutter/test/gestures/scale_test.dart +++ b/packages/flutter/test/gestures/scale_test.dart @@ -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(['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 log = []; + + 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(['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, ['scale-end']); + log.clear(); + + tester.route(pointer2.panZoomUpdate(const Offset(10.0, 20.0), pan: const Offset(20.0, 0.0))); + expect(log, equals(['scale-start', 'scale-update'])); + log.clear(); + + tester.route(pointer1.panZoomEnd()); + expect(log, equals(['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(['scale-start', 'scale-update'])); + log.clear(); + + tester.route(pointer3.panZoomEnd()); + expect(log, equals(['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( () { diff --git a/packages/flutter/test/widgets/keep_alive_test.dart b/packages/flutter/test/widgets/keep_alive_test.dart index 9a819ea287..116291d013 100644 --- a/packages/flutter/test/widgets/keep_alive_test.dart +++ b/packages/flutter/test/widgets/keep_alive_test.dart @@ -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' diff --git a/packages/flutter/test/widgets/scroll_behavior_test.dart b/packages/flutter/test/widgets/scroll_behavior_test.dart index 99a97a8c17..434990a265 100644 --- a/packages/flutter/test/widgets/scroll_behavior_test.dart +++ b/packages/flutter/test/widgets/scroll_behavior_test.dart @@ -134,6 +134,7 @@ void main() { PointerDeviceKind.touch, PointerDeviceKind.stylus, PointerDeviceKind.invertedStylus, + PointerDeviceKind.trackpad, PointerDeviceKind.unknown, }); diff --git a/packages/flutter_test/lib/src/test_pointer.dart b/packages/flutter_test/lib/src/test_pointer.dart index c432b782f0..7e53c3856c 100644 --- a/packages/flutter_test/lib/src/test_pointer.dart +++ b/packages/flutter_test/lib/src/test_pointer.dart @@ -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 diff --git a/packages/flutter_tools/templates/app_shared/ios.tmpl/Runner/Info.plist.tmpl b/packages/flutter_tools/templates/app_shared/ios.tmpl/Runner/Info.plist.tmpl index e7cece9d6c..3d4696c092 100644 --- a/packages/flutter_tools/templates/app_shared/ios.tmpl/Runner/Info.plist.tmpl +++ b/packages/flutter_tools/templates/app_shared/ios.tmpl/Runner/Info.plist.tmpl @@ -45,5 +45,7 @@ CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents + diff --git a/packages/flutter_tools/templates/module/ios/host_app_ephemeral/Runner.tmpl/Info.plist.tmpl b/packages/flutter_tools/templates/module/ios/host_app_ephemeral/Runner.tmpl/Info.plist.tmpl index 26210381d1..5c9b912802 100644 --- a/packages/flutter_tools/templates/module/ios/host_app_ephemeral/Runner.tmpl/Info.plist.tmpl +++ b/packages/flutter_tools/templates/module/ios/host_app_ephemeral/Runner.tmpl/Info.plist.tmpl @@ -45,5 +45,7 @@ CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents + diff --git a/packages/flutter_tools/test/commands.shard/permeable/create_test.dart b/packages/flutter_tools/test/commands.shard/permeable/create_test.dart index 16ecc75d46..705bb44cd8 100755 --- a/packages/flutter_tools/test/commands.shard/permeable/create_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/create_test.dart @@ -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: { @@ -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: { @@ -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'); });