From 7ddf42eae5ee9b9d770ce40aae362b0fc2ca1005 Mon Sep 17 00:00:00 2001 From: Callum Moffat Date: Fri, 6 Jan 2023 17:44:44 -0500 Subject: [PATCH] InteractiveViewer parameter to return to pre-3.3 trackpad/Magic Mouse behaviour (#114280) * trackpadPanShouldActAsZoom * Address feedback * Move constant, add blank lines --- packages/flutter/lib/src/gestures/scale.dart | 155 ++++++++++---- .../lib/src/widgets/gesture_detector.dart | 12 +- .../lib/src/widgets/interactive_viewer.dart | 149 ++++++++++---- .../flutter/test/gestures/scale_test.dart | 192 ++++++++++++++++++ .../test/widgets/interactive_viewer_test.dart | 72 +++++++ 5 files changed, 501 insertions(+), 79 deletions(-) diff --git a/packages/flutter/lib/src/gestures/scale.dart b/packages/flutter/lib/src/gestures/scale.dart index 500c2b987122..6655ee6e9ed7 100644 --- a/packages/flutter/lib/src/gestures/scale.dart +++ b/packages/flutter/lib/src/gestures/scale.dart @@ -15,6 +15,18 @@ export 'events.dart' show PointerDownEvent, PointerEvent, PointerPanZoomStartEve export 'recognizer.dart' show DragStartBehavior; export 'velocity_tracker.dart' show Velocity; +/// The default conversion factor when treating mouse scrolling as scaling. +/// +/// The value was arbitrarily chosen to feel natural for most mousewheels on +/// all supported platforms. +const double kDefaultMouseScrollToScaleFactor = 200; + +/// The default conversion factor when treating trackpad scrolling as scaling. +/// +/// This factor matches the default [kDefaultMouseScrollToScaleFactor] of 200 to +/// feel natural for most trackpads, and the convention that scrolling up means +/// zooming in. +const Offset kDefaultTrackpadScrollToScaleFactor = Offset(0, -1/kDefaultMouseScrollToScaleFactor); /// The possible states of a [ScaleGestureRecognizer]. enum _ScaleState { @@ -36,17 +48,49 @@ enum _ScaleState { } class _PointerPanZoomData { - _PointerPanZoomData({ - required this.focalPoint, - required this.scale, - required this.rotation - }); - Offset focalPoint; - double scale; - double rotation; + _PointerPanZoomData.fromStartEvent( + this.parent, + PointerPanZoomStartEvent event + ) : _position = event.position, + _pan = Offset.zero, + _scale = 1, + _rotation = 0; + + _PointerPanZoomData.fromUpdateEvent( + this.parent, + PointerPanZoomUpdateEvent event + ) : _position = event.position, + _pan = event.pan, + _scale = event.scale, + _rotation = event.rotation; + + final ScaleGestureRecognizer parent; + final Offset _position; + final Offset _pan; + final double _scale; + final double _rotation; + + Offset get focalPoint { + if (parent.trackpadScrollCausesScale) { + return _position; + } + return _position + _pan; + } + + double get scale { + if (parent.trackpadScrollCausesScale) { + return _scale * math.exp( + (_pan.dx * parent.trackpadScrollToScaleFactor.dx) + + (_pan.dy * parent.trackpadScrollToScaleFactor.dy) + ); + } + return _scale; + } + + double get rotation => _rotation; @override - String toString() => '_PointerPanZoomData(focalPoint: $focalPoint, scale: $scale, angle: $rotation)'; + String toString() => '_PointerPanZoomData(parent: $parent, _position: $_position, _pan: $_pan, _scale: $_scale, _rotation: $_rotation)'; } /// Details for [GestureScaleStartCallback]. @@ -54,8 +98,11 @@ class ScaleStartDetails { /// Creates details for [GestureScaleStartCallback]. /// /// The [focalPoint] argument must not be null. - ScaleStartDetails({ this.focalPoint = Offset.zero, Offset? localFocalPoint, this.pointerCount = 0 }) - : assert(focalPoint != null), localFocalPoint = localFocalPoint ?? focalPoint; + ScaleStartDetails({ + this.focalPoint = Offset.zero, + Offset? localFocalPoint, + this.pointerCount = 0, + }) : assert(focalPoint != null), localFocalPoint = localFocalPoint ?? focalPoint; /// The initial focal point of the pointers in contact with the screen. /// @@ -201,12 +248,15 @@ class ScaleEndDetails { /// Creates details for [GestureScaleEndCallback]. /// /// The [velocity] argument must not be null. - ScaleEndDetails({ this.velocity = Velocity.zero, this.pointerCount = 0 }) + ScaleEndDetails({ this.velocity = Velocity.zero, this.scaleVelocity = 0, this.pointerCount = 0 }) : assert(velocity != null); /// The velocity of the last pointer to be lifted off of the screen. final Velocity velocity; + /// The final velocity of the scale factor reported by the gesture. + final double scaleVelocity; + /// The number of pointers being tracked by the gesture recognizer. /// /// Typically this is the number of fingers being used to pan the widget using the gesture @@ -214,7 +264,7 @@ class ScaleEndDetails { final int pointerCount; @override - String toString() => 'ScaleEndDetails(velocity: $velocity, pointerCount: $pointerCount)'; + String toString() => 'ScaleEndDetails(velocity: $velocity, scaleVelocity: $scaleVelocity, pointerCount: $pointerCount)'; } /// Signature for when the pointers in contact with the screen have established @@ -285,6 +335,8 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { super.kind, super.supportedDevices, this.dragStartBehavior = DragStartBehavior.down, + this.trackpadScrollCausesScale = false, + this.trackpadScrollToScaleFactor = kDefaultTrackpadScrollToScaleFactor, }) : assert(dragStartBehavior != null); /// Determines what point is used as the starting point in all calculations @@ -332,6 +384,26 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { Matrix4? _lastTransform; + /// {@template flutter.gestures.scale.trackpadScrollCausesScale} + /// Whether scrolling up/down on a trackpad should cause scaling instead of + /// panning. + /// + /// Defaults to false. + /// {@endtemplate} + bool trackpadScrollCausesScale; + + /// {@template flutter.gestures.scale.trackpadScrollToScaleFactor} + /// A factor to control the direction and magnitude of scale when converting + /// trackpad scrolling. + /// + /// Incoming trackpad pan offsets will be divided by this factor to get scale + /// values. Increasing this offset will reduce the amount of scaling caused by + /// a fixed amount of trackpad scrolling. + /// + /// Defaults to [kDefaultTrackpadScrollToScaleFactor]. + /// {@endtemplate} + Offset trackpadScrollToScaleFactor; + late Offset _initialFocalPoint; Offset? _currentFocalPoint; late double _initialSpan; @@ -346,6 +418,7 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { final Map _pointerLocations = {}; final List _pointerQueue = []; // A queue to sort pointers in order of entrance final Map _velocityTrackers = {}; + VelocityTracker? _scaleVelocityTracker; late Offset _delta; final Map _pointerPanZooms = {}; double _initialPanZoomScaleFactor = 1; @@ -466,23 +539,16 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { _lastTransform = event.transform; } else if (event is PointerPanZoomStartEvent) { assert(_pointerPanZooms[event.pointer] == null); - _pointerPanZooms[event.pointer] = _PointerPanZoomData( - focalPoint: event.position, - scale: 1, - rotation: 0 - ); + _pointerPanZooms[event.pointer] = _PointerPanZoomData.fromStartEvent(this, event); didChangeConfiguration = true; shouldStartIfAccepted = true; + _lastTransform = event.transform; } else if (event is PointerPanZoomUpdateEvent) { assert(_pointerPanZooms[event.pointer] != null); - if (!event.synthesized) { + if (!event.synthesized && !trackpadScrollCausesScale) { _velocityTrackers[event.pointer]!.addPosition(event.timeStamp, event.pan); } - _pointerPanZooms[event.pointer] = _PointerPanZoomData( - focalPoint: event.position + event.pan, - scale: event.scale, - rotation: event.rotation - ); + _pointerPanZooms[event.pointer] = _PointerPanZoomData.fromUpdateEvent(this, event); _lastTransform = event.transform; shouldStartIfAccepted = true; } else if (event is PointerPanZoomEndEvent) { @@ -495,7 +561,7 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { _update(); if (!didChangeConfiguration || _reconfigure(event.pointer)) { - _advanceStateMachine(shouldStartIfAccepted, event.kind); + _advanceStateMachine(shouldStartIfAccepted, event); } stopTrackingIfPointerNoLongerDown(event); } @@ -607,18 +673,20 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) { velocity = Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity); } - invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerCount))); + invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, pointerCount: _pointerCount))); } else { - invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(pointerCount: _pointerCount))); + invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(scaleVelocity: _scaleVelocityTracker?.getVelocity().pixelsPerSecond.dx ?? -1, pointerCount: _pointerCount))); } } _state = _ScaleState.accepted; + _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); // arbitrary PointerDeviceKind return false; } + _scaleVelocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); // arbitrary PointerDeviceKind return true; } - void _advanceStateMachine(bool shouldStartIfAccepted, PointerDeviceKind pointerDeviceKind) { + void _advanceStateMachine(bool shouldStartIfAccepted, PointerEvent event) { if (_state == _ScaleState.ready) { _state = _ScaleState.possible; } @@ -626,7 +694,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) || math.max(_scaleFactor / _pointerScaleFactor, _pointerScaleFactor / _scaleFactor) > 1.05) { + if (spanDelta > computeScaleSlop(event.kind) || focalPointDelta > computePanSlop(event.kind, gestureSettings) || math.max(_scaleFactor / _pointerScaleFactor, _pointerScaleFactor / _scaleFactor) > 1.05) { resolve(GestureDisposition.accepted); } } else if (_state.index >= _ScaleState.accepted.index) { @@ -638,19 +706,22 @@ class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { _dispatchOnStartCallbackIfNeeded(); } - if (_state == _ScaleState.started && onUpdate != null) { - invokeCallback('onUpdate', () { - onUpdate!(ScaleUpdateDetails( - scale: _scaleFactor, - horizontalScale: _horizontalScaleFactor, - verticalScale: _verticalScaleFactor, - focalPoint: _currentFocalPoint!, - localFocalPoint: _localFocalPoint, - rotation: _computeRotationFactor(), - pointerCount: _pointerCount, - focalPointDelta: _delta, - )); - }); + if (_state == _ScaleState.started) { + _scaleVelocityTracker?.addPosition(event.timeStamp, Offset(_scaleFactor, 0)); + if (onUpdate != null) { + invokeCallback('onUpdate', () { + onUpdate!(ScaleUpdateDetails( + scale: _scaleFactor, + horizontalScale: _horizontalScaleFactor, + verticalScale: _verticalScaleFactor, + focalPoint: _currentFocalPoint!, + localFocalPoint: _localFocalPoint, + rotation: _computeRotationFactor(), + pointerCount: _pointerCount, + focalPointDelta: _delta, + )); + }); + } } } diff --git a/packages/flutter/lib/src/widgets/gesture_detector.dart b/packages/flutter/lib/src/widgets/gesture_detector.dart index 2130e0af9e8f..91a53efea0bc 100644 --- a/packages/flutter/lib/src/widgets/gesture_detector.dart +++ b/packages/flutter/lib/src/widgets/gesture_detector.dart @@ -288,6 +288,8 @@ class GestureDetector extends StatelessWidget { this.behavior, this.excludeFromSemantics = false, this.dragStartBehavior = DragStartBehavior.start, + this.trackpadScrollCausesScale = false, + this.trackpadScrollToScaleFactor = kDefaultTrackpadScrollToScaleFactor, this.supportedDevices, }) : assert(excludeFromSemantics != null), assert(dragStartBehavior != null), @@ -1014,6 +1016,12 @@ class GestureDetector extends StatelessWidget { /// If set to null, events from all device types will be recognized. Defaults to null. final Set? supportedDevices; + /// {@macro flutter.gestures.scale.trackpadScrollCausesScale} + final bool trackpadScrollCausesScale; + + /// {@macro flutter.gestures.scale.trackpadScrollToScaleFactor} + final Offset trackpadScrollToScaleFactor; + @override Widget build(BuildContext context) { final Map gestures = {}; @@ -1186,7 +1194,9 @@ class GestureDetector extends StatelessWidget { ..onUpdate = onScaleUpdate ..onEnd = onScaleEnd ..dragStartBehavior = dragStartBehavior - ..gestureSettings = gestureSettings; + ..gestureSettings = gestureSettings + ..trackpadScrollCausesScale = trackpadScrollCausesScale + ..trackpadScrollToScaleFactor = trackpadScrollToScaleFactor; }, ); } diff --git a/packages/flutter/lib/src/widgets/interactive_viewer.dart b/packages/flutter/lib/src/widgets/interactive_viewer.dart index f4bf64efd430..190fbbdd22c3 100644 --- a/packages/flutter/lib/src/widgets/interactive_viewer.dart +++ b/packages/flutter/lib/src/widgets/interactive_viewer.dart @@ -85,9 +85,10 @@ class InteractiveViewer extends StatefulWidget { this.onInteractionUpdate, this.panEnabled = true, this.scaleEnabled = true, - this.scaleFactor = 200.0, + this.scaleFactor = kDefaultMouseScrollToScaleFactor, this.transformationController, this.alignment, + this.trackpadScrollCausesScale = false, required Widget this.child, }) : assert(alignPanAxis != null), assert(panAxis != null), @@ -103,6 +104,7 @@ class InteractiveViewer extends StatefulWidget { assert(maxScale >= minScale), assert(panEnabled != null), assert(scaleEnabled != null), + assert(trackpadScrollCausesScale != null), // boundaryMargin must be either fully infinite or fully finite, but not // a mix of both. assert( @@ -143,6 +145,7 @@ class InteractiveViewer extends StatefulWidget { this.scaleFactor = 200.0, this.transformationController, this.alignment, + this.trackpadScrollCausesScale = false, required InteractiveViewerWidgetBuilder this.builder, }) : assert(panAxis != null), assert(builder != null), @@ -156,6 +159,7 @@ class InteractiveViewer extends StatefulWidget { assert(maxScale >= minScale), assert(panEnabled != null), assert(scaleEnabled != null), + assert(trackpadScrollCausesScale != null), // boundaryMargin must be either fully infinite or fully finite, but not // a mix of both. assert( @@ -295,10 +299,12 @@ class InteractiveViewer extends StatefulWidget { /// * [panEnabled], which is similar but for panning. final bool scaleEnabled; + /// {@macro flutter.gestures.scale.trackpadScrollCausesScale} + final bool trackpadScrollCausesScale; + /// Determines the amount of scale to be performed per pointer scroll. /// - /// Defaults to 200.0, which was arbitrarily chosen to feel natural for most - /// trackpads and mousewheels on all supported platforms. + /// Defaults to [kDefaultMouseScrollToScaleFactor]. /// /// Increasing this value above the default causes scaling to feel slower, /// while decreasing it causes scaling to feel faster. @@ -556,7 +562,10 @@ class _InteractiveViewerState extends State with TickerProvid final GlobalKey _childKey = GlobalKey(); final GlobalKey _parentKey = GlobalKey(); Animation? _animation; + Animation? _scaleAnimation; + late Offset _scaleAnimationFocalPoint; late AnimationController _controller; + late AnimationController _scaleController; Axis? _currentAxis; // Used with panAxis. Offset? _referenceFocalPoint; // Point where the current gesture began. double? _scaleStart; // Scale value at start of scaling gesture. @@ -795,6 +804,12 @@ class _InteractiveViewerState extends State with TickerProvid _animation?.removeListener(_onAnimate); _animation = null; } + if (_scaleController.isAnimating) { + _scaleController.stop(); + _scaleController.reset(); + _scaleAnimation?.removeListener(_onScaleAnimate); + _scaleAnimation = null; + } _gestureType = null; _currentAxis = null; @@ -809,6 +824,7 @@ class _InteractiveViewerState extends State with TickerProvid // handled with GestureDetector's scale gesture. void _onScaleUpdate(ScaleUpdateDetails details) { final double scale = _transformationController!.value.getMaxScaleOnAxis(); + _scaleAnimationFocalPoint = details.localFocalPoint; final Offset focalPointScene = _transformationController!.toScene( details.localFocalPoint, ); @@ -913,45 +929,69 @@ class _InteractiveViewerState extends State with TickerProvid _referenceFocalPoint = null; _animation?.removeListener(_onAnimate); + _scaleAnimation?.removeListener(_onScaleAnimate); _controller.reset(); + _scaleController.reset(); if (!_gestureIsSupported(_gestureType)) { _currentAxis = null; return; } - // If the scale ended with enough velocity, animate inertial movement. - if (_gestureType != _GestureType.pan || details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { - _currentAxis = null; - return; + if (_gestureType == _GestureType.pan) { + if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { + _currentAxis = null; + return; + } + final Vector3 translationVector = _transformationController!.value.getTranslation(); + final Offset translation = Offset(translationVector.x, translationVector.y); + final FrictionSimulation frictionSimulationX = FrictionSimulation( + widget.interactionEndFrictionCoefficient, + translation.dx, + details.velocity.pixelsPerSecond.dx, + ); + final FrictionSimulation frictionSimulationY = FrictionSimulation( + widget.interactionEndFrictionCoefficient, + translation.dy, + details.velocity.pixelsPerSecond.dy, + ); + final double tFinal = _getFinalTime( + details.velocity.pixelsPerSecond.distance, + widget.interactionEndFrictionCoefficient, + ); + _animation = Tween( + begin: translation, + end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX), + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.decelerate, + )); + _controller.duration = Duration(milliseconds: (tFinal * 1000).round()); + _animation!.addListener(_onAnimate); + _controller.forward(); + } else if (_gestureType == _GestureType.scale) { + if (details.scaleVelocity.abs() < 0.1) { + _currentAxis = null; + return; + } + final double scale = _transformationController!.value.getMaxScaleOnAxis(); + final FrictionSimulation frictionSimulation = FrictionSimulation( + widget.interactionEndFrictionCoefficient * widget.scaleFactor, + scale, + details.scaleVelocity / 10 + ); + final double tFinal = _getFinalTime(details.scaleVelocity.abs(), widget.interactionEndFrictionCoefficient, effectivelyMotionless: 0.1); + _scaleAnimation = Tween( + begin: scale, + end: frictionSimulation.x(tFinal) + ).animate(CurvedAnimation( + parent: _scaleController, + curve: Curves.decelerate + )); + _scaleController.duration = Duration(milliseconds: (tFinal * 1000).round()); + _scaleAnimation!.addListener(_onScaleAnimate); + _scaleController.forward(); } - - final Vector3 translationVector = _transformationController!.value.getTranslation(); - final Offset translation = Offset(translationVector.x, translationVector.y); - final FrictionSimulation frictionSimulationX = FrictionSimulation( - widget.interactionEndFrictionCoefficient, - translation.dx, - details.velocity.pixelsPerSecond.dx, - ); - final FrictionSimulation frictionSimulationY = FrictionSimulation( - widget.interactionEndFrictionCoefficient, - translation.dy, - details.velocity.pixelsPerSecond.dy, - ); - final double tFinal = _getFinalTime( - details.velocity.pixelsPerSecond.distance, - widget.interactionEndFrictionCoefficient, - ); - _animation = Tween( - begin: translation, - end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX), - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.decelerate, - )); - _controller.duration = Duration(milliseconds: (tFinal * 1000).round()); - _animation!.addListener(_onAnimate); - _controller.forward(); } // Handle mousewheel and web trackpad scroll events. @@ -1085,6 +1125,38 @@ class _InteractiveViewerState extends State with TickerProvid ); } + // Handle inertia scale animation. + void _onScaleAnimate() { + if (!_scaleController.isAnimating) { + _currentAxis = null; + _scaleAnimation?.removeListener(_onScaleAnimate); + _scaleAnimation = null; + _scaleController.reset(); + return; + } + final double desiredScale = _scaleAnimation!.value; + final double scaleChange = desiredScale / _transformationController!.value.getMaxScaleOnAxis(); + final Offset referenceFocalPoint = _transformationController!.toScene( + _scaleAnimationFocalPoint, + ); + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // While scaling, translate such that the user's two fingers stay on + // the same places in the scene. That means that the focal point of + // the scale should be on the same place in the scene before and after + // the scale. + final Offset focalPointSceneScaled = _transformationController!.toScene( + _scaleAnimationFocalPoint, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - referenceFocalPoint, + ); + } + void _onTransformationControllerChange() { // A change to the TransformationController's value is a change to the // state. @@ -1101,6 +1173,9 @@ class _InteractiveViewerState extends State with TickerProvid _controller = AnimationController( vsync: this, ); + _scaleController = AnimationController( + vsync: this + ); } @override @@ -1131,6 +1206,7 @@ class _InteractiveViewerState extends State with TickerProvid @override void dispose() { _controller.dispose(); + _scaleController.dispose(); _transformationController!.removeListener(_onTransformationControllerChange); if (widget.transformationController == null) { _transformationController!.dispose(); @@ -1181,6 +1257,8 @@ class _InteractiveViewerState extends State with TickerProvid onScaleEnd: _onScaleEnd, onScaleStart: _onScaleStart, onScaleUpdate: _onScaleUpdate, + trackpadScrollCausesScale: widget.trackpadScrollCausesScale, + trackpadScrollToScaleFactor: Offset(0, -1/widget.scaleFactor), child: child, ), ); @@ -1305,8 +1383,7 @@ enum _GestureType { // Given a velocity and drag, calculate the time at which motion will come to // a stop, within the margin of effectivelyMotionless. -double _getFinalTime(double velocity, double drag) { - const double effectivelyMotionless = 10.0; +double _getFinalTime(double velocity, double drag, {double effectivelyMotionless = 10}) { return math.log(effectivelyMotionless / velocity) / math.log(drag / 100); } diff --git a/packages/flutter/test/gestures/scale_test.dart b/packages/flutter/test/gestures/scale_test.dart index ea601fc0c139..471f17ccfab6 100644 --- a/packages/flutter/test/gestures/scale_test.dart +++ b/packages/flutter/test/gestures/scale_test.dart @@ -1173,4 +1173,196 @@ void main() { ), ); }); + + testGesture('scale trackpadScrollCausesScale', (GestureTester tester) { + final ScaleGestureRecognizer scale = ScaleGestureRecognizer( + dragStartBehavior: DragStartBehavior.start, + trackpadScrollCausesScale: true + ); + + bool didStartScale = false; + Offset? updatedFocalPoint; + scale.onStart = (ScaleStartDetails details) { + didStartScale = true; + updatedFocalPoint = details.focalPoint; + }; + + double? updatedScale; + Offset? updatedDelta; + scale.onUpdate = (ScaleUpdateDetails details) { + updatedScale = details.scale; + updatedFocalPoint = details.focalPoint; + updatedDelta = details.focalPointDelta; + }; + + bool didEndScale = false; + scale.onEnd = (ScaleEndDetails details) { + didEndScale = true; + }; + + final TestPointer pointer1 = TestPointer(2, PointerDeviceKind.trackpad); + + final PointerPanZoomStartEvent start = pointer1.panZoomStart(Offset.zero); + scale.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, isTrue); + didStartScale = false; + expect(updatedScale, isNull); + expect(updatedFocalPoint, Offset.zero); + updatedFocalPoint = null; + expect(updatedDelta, isNull); + expect(didEndScale, isFalse); + + // Zoom in by scrolling up. + tester.route(pointer1.panZoomUpdate(Offset.zero, pan: const Offset(0, -200))); + expect(didStartScale, isFalse); + expect(updatedFocalPoint, Offset.zero); + updatedFocalPoint = null; + expect(updatedScale, math.e); + updatedScale = null; + expect(updatedDelta, Offset.zero); + updatedDelta = null; + expect(didEndScale, isFalse); + + // A horizontal scroll should do nothing. + tester.route(pointer1.panZoomUpdate(Offset.zero, pan: const Offset(200, -200))); + expect(didStartScale, isFalse); + expect(updatedFocalPoint, Offset.zero); + updatedFocalPoint = null; + expect(updatedScale, math.e); + updatedScale = null; + expect(updatedDelta, Offset.zero); + updatedDelta = null; + expect(didEndScale, isFalse); + + // End. + tester.route(pointer1.panZoomEnd()); + expect(didStartScale, isFalse); + expect(updatedFocalPoint, isNull); + expect(updatedScale, isNull); + expect(updatedDelta, isNull); + expect(didEndScale, isTrue); + didEndScale = false; + + // Try with a different trackpadScrollToScaleFactor + scale.trackpadScrollToScaleFactor = const Offset(1/125, 0); + + final PointerPanZoomStartEvent start2 = pointer1.panZoomStart(Offset.zero); + scale.addPointerPanZoom(start2); + + tester.closeArena(2); + expect(didStartScale, isFalse); + expect(updatedScale, isNull); + expect(updatedFocalPoint, isNull); + expect(updatedDelta, isNull); + expect(didEndScale, isFalse); + + tester.route(start2); + expect(didStartScale, isTrue); + didStartScale = false; + expect(updatedScale, isNull); + expect(updatedFocalPoint, Offset.zero); + updatedFocalPoint = null; + expect(updatedDelta, isNull); + expect(didEndScale, isFalse); + + // Zoom in by scrolling left. + tester.route(pointer1.panZoomUpdate(Offset.zero, pan: const Offset(125, 0))); + expect(didStartScale, isFalse); + didStartScale = false; + expect(updatedFocalPoint, Offset.zero); + updatedFocalPoint = null; + expect(updatedScale, math.e); + updatedScale = null; + expect(updatedDelta, Offset.zero); + updatedDelta = null; + expect(didEndScale, isFalse); + + // A vertical scroll should do nothing. + tester.route(pointer1.panZoomUpdate(Offset.zero, pan: const Offset(125, 125))); + expect(didStartScale, isFalse); + expect(updatedFocalPoint, Offset.zero); + updatedFocalPoint = null; + expect(updatedScale, math.e); + updatedScale = null; + expect(updatedDelta, Offset.zero); + updatedDelta = null; + expect(didEndScale, isFalse); + + // End. + tester.route(pointer1.panZoomEnd()); + expect(didStartScale, isFalse); + expect(updatedFocalPoint, isNull); + expect(updatedScale, isNull); + expect(updatedDelta, isNull); + expect(didEndScale, isTrue); + didEndScale = false; + + scale.dispose(); + }); + + testGesture('scale ending velocity', (GestureTester tester) { + final ScaleGestureRecognizer scale = ScaleGestureRecognizer( + dragStartBehavior: DragStartBehavior.start, + trackpadScrollCausesScale: true + ); + + bool didStartScale = false; + Offset? updatedFocalPoint; + scale.onStart = (ScaleStartDetails details) { + didStartScale = true; + updatedFocalPoint = details.focalPoint; + }; + + bool didEndScale = false; + double? scaleEndVelocity; + scale.onEnd = (ScaleEndDetails details) { + didEndScale = true; + scaleEndVelocity = details.scaleVelocity; + }; + + final TestPointer pointer1 = TestPointer(2, PointerDeviceKind.trackpad); + + final PointerPanZoomStartEvent start = pointer1.panZoomStart(Offset.zero); + scale.addPointerPanZoom(start); + + tester.closeArena(2); + expect(didStartScale, isFalse); + expect(updatedFocalPoint, isNull); + expect(didEndScale, isFalse); + + tester.route(start); + expect(didStartScale, isTrue); + didStartScale = false; + expect(updatedFocalPoint, Offset.zero); + updatedFocalPoint = null; + expect(didEndScale, isFalse); + + // Zoom in by scrolling up. + for (int i = 0; i < 100; i++) { + tester.route(pointer1.panZoomUpdate( + Offset.zero, + pan: Offset(0, i * -10), + timeStamp: Duration(milliseconds: i * 25) + )); + } + + // End. + tester.route(pointer1.panZoomEnd(timeStamp: const Duration(milliseconds: 2500))); + expect(didStartScale, isFalse); + expect(updatedFocalPoint, isNull); + expect(didEndScale, isTrue); + didEndScale = false; + expect(scaleEndVelocity, moreOrLessEquals(281.41454098027765)); + + scale.dispose(); + }); } diff --git a/packages/flutter/test/widgets/interactive_viewer_test.dart b/packages/flutter/test/widgets/interactive_viewer_test.dart index c83ce608051f..8db3d2959ae2 100644 --- a/packages/flutter/test/widgets/interactive_viewer_test.dart +++ b/packages/flutter/test/widgets/interactive_viewer_test.dart @@ -1802,6 +1802,78 @@ void main() { await tester.pump(); expect(transformationController.value.getMaxScaleOnAxis(), 2.5); // capped at maxScale (2.5) }); + + testWidgets('trackpadScrollCausesScale', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + const double boundaryMargin = 50.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + boundaryMargin: const EdgeInsets.all(boundaryMargin), + transformationController: transformationController, + trackpadScrollCausesScale: true, + child: const SizedBox(width: 200.0, height: 200.0), + ), + ), + ), + ), + ); + + expect(transformationController.value.getMaxScaleOnAxis(), 1.0); + + // Send a vertical scroll. + final TestPointer pointer = TestPointer(1, PointerDeviceKind.trackpad); + final Offset center = tester.getCenter(find.byType(SizedBox)); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.pump(); + expect(transformationController.value.getMaxScaleOnAxis(), 1.0); + await tester.sendEventToBinding(pointer.panZoomUpdate(center, pan: const Offset(0, -81))); + await tester.pump(); + expect(transformationController.value.getMaxScaleOnAxis(), moreOrLessEquals(1.499302500056767)); + + // Send a horizontal scroll (should have no effect). + await tester.sendEventToBinding(pointer.panZoomUpdate(center, pan: const Offset(81, -81))); + await tester.pump(); + expect(transformationController.value.getMaxScaleOnAxis(), moreOrLessEquals(1.499302500056767)); + }); + + testWidgets('Scaling inertia', (WidgetTester tester) async { + final TransformationController transformationController = TransformationController(); + const double boundaryMargin = 50.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InteractiveViewer( + boundaryMargin: const EdgeInsets.all(boundaryMargin), + transformationController: transformationController, + trackpadScrollCausesScale: true, + child: const SizedBox(width: 200.0, height: 200.0), + ), + ), + ), + ), + ); + + expect(transformationController.value.getMaxScaleOnAxis(), 1.0); + + // Send a vertical scroll fling, which will cause inertia. + await tester.trackpadFling( + find.byType(InteractiveViewer), + const Offset(0, -100), + 3000 + ); + await tester.pump(); + expect(transformationController.value.getMaxScaleOnAxis(), moreOrLessEquals(1.6487212707001282)); + await tester.pump(const Duration(milliseconds: 80)); + expect(transformationController.value.getMaxScaleOnAxis(), moreOrLessEquals(1.7966838346780103)); + await tester.pumpAndSettle(); + expect(transformationController.value.getMaxScaleOnAxis(), moreOrLessEquals(1.9984509673751225)); + await tester.pump(const Duration(seconds: 10)); + expect(transformationController.value.getMaxScaleOnAxis(), moreOrLessEquals(1.9984509673751225)); + }); }); group('getNearestPointOnLine', () {