| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:math' as math; |
| |
| import 'package:vector_math/vector_math_64.dart'; |
| |
| import 'arena.dart'; |
| import 'constants.dart'; |
| import 'events.dart'; |
| import 'recognizer.dart'; |
| import 'velocity_tracker.dart'; |
| |
| /// The possible states of a [ScaleGestureRecognizer]. |
| enum _ScaleState { |
| /// The recognizer is ready to start recognizing a gesture. |
| ready, |
| |
| /// The sequence of pointer events seen thus far is consistent with a scale |
| /// gesture but the gesture has not been accepted definitively. |
| possible, |
| |
| /// The sequence of pointer events seen thus far has been accepted |
| /// definitively as a scale gesture. |
| accepted, |
| |
| /// The sequence of pointer events seen thus far has been accepted |
| /// definitively as a scale gesture and the pointers established a focal point |
| /// and initial scale. |
| started, |
| } |
| |
| /// Details for [GestureScaleStartCallback]. |
| class ScaleStartDetails { |
| /// Creates details for [GestureScaleStartCallback]. |
| /// |
| /// The [focalPoint] argument must not be null. |
| ScaleStartDetails({ this.focalPoint = Offset.zero, Offset localFocalPoint, }) |
| : assert(focalPoint != null), localFocalPoint = localFocalPoint ?? focalPoint; |
| |
| /// The initial focal point of the pointers in contact with the screen. |
| /// |
| /// Reported in global coordinates. |
| /// |
| /// See also: |
| /// |
| /// * [localFocalPoint], which is the same value reported in local |
| /// coordinates. |
| final Offset focalPoint; |
| |
| /// The initial focal point of the pointers in contact with the screen. |
| /// |
| /// Reported in local coordinates. Defaults to [focalPoint] if not set in the |
| /// constructor. |
| /// |
| /// See also: |
| /// |
| /// * [focalPoint], which is the same value reported in global |
| /// coordinates. |
| final Offset localFocalPoint; |
| |
| @override |
| String toString() => 'ScaleStartDetails(focalPoint: $focalPoint, localFocalPoint: $localFocalPoint)'; |
| } |
| |
| /// Details for [GestureScaleUpdateCallback]. |
| class ScaleUpdateDetails { |
| /// Creates details for [GestureScaleUpdateCallback]. |
| /// |
| /// The [focalPoint], [scale], [horizontalScale], [verticalScale], [rotation] |
| /// arguments must not be null. The [scale], [horizontalScale], and [verticalScale] |
| /// argument must be greater than or equal to zero. |
| ScaleUpdateDetails({ |
| this.focalPoint = Offset.zero, |
| Offset localFocalPoint, |
| this.scale = 1.0, |
| this.horizontalScale = 1.0, |
| this.verticalScale = 1.0, |
| this.rotation = 0.0, |
| }) : assert(focalPoint != null), |
| assert(scale != null && scale >= 0.0), |
| assert(horizontalScale != null && horizontalScale >= 0.0), |
| assert(verticalScale != null && verticalScale >= 0.0), |
| assert(rotation != null), |
| localFocalPoint = localFocalPoint ?? focalPoint; |
| |
| /// The focal point of the pointers in contact with the screen. |
| /// |
| /// Reported in global coordinates. |
| /// |
| /// See also: |
| /// |
| /// * [localFocalPoint], which is the same value reported in local |
| /// coordinates. |
| final Offset focalPoint; |
| |
| /// The focal point of the pointers in contact with the screen. |
| /// |
| /// Reported in local coordinates. Defaults to [focalPoint] if not set in the |
| /// constructor. |
| /// |
| /// See also: |
| /// |
| /// * [focalPoint], which is the same value reported in global |
| /// coordinates. |
| final Offset localFocalPoint; |
| |
| /// The scale implied by the average distance between the pointers in contact |
| /// with the screen. |
| /// |
| /// This value must be greater than or equal to zero. |
| /// |
| /// See also: |
| /// |
| /// * [horizontalScale], which is the scale along the horizontal axis. |
| /// * [verticalScale], which is the scale along the vertical axis. |
| final double scale; |
| |
| /// The scale implied by the average distance along the horizontal axis |
| /// between the pointers in contact with the screen. |
| /// |
| /// This value must be greater than or equal to zero. |
| /// |
| /// See also: |
| /// |
| /// * [scale], which is the general scale implied by the pointers. |
| /// * [verticalScale], which is the scale along the vertical axis. |
| final double horizontalScale; |
| |
| /// The scale implied by the average distance along the vertical axis |
| /// between the pointers in contact with the screen. |
| /// |
| /// This value must be greater than or equal to zero. |
| /// |
| /// See also: |
| /// |
| /// * [scale], which is the general scale implied by the pointers. |
| /// * [horizontalScale], which is the scale along the horizontal axis. |
| final double verticalScale; |
| |
| /// The angle implied by the first two pointers to enter in contact with |
| /// the screen. |
| /// |
| /// Expressed in radians. |
| final double rotation; |
| |
| @override |
| String toString() => 'ScaleUpdateDetails(focalPoint: $focalPoint, localFocalPoint: $localFocalPoint, scale: $scale, horizontalScale: $horizontalScale, verticalScale: $verticalScale, rotation: $rotation)'; |
| } |
| |
| /// Details for [GestureScaleEndCallback]. |
| class ScaleEndDetails { |
| /// Creates details for [GestureScaleEndCallback]. |
| /// |
| /// The [velocity] argument must not be null. |
| ScaleEndDetails({ this.velocity = Velocity.zero }) |
| : assert(velocity != null); |
| |
| /// The velocity of the last pointer to be lifted off of the screen. |
| final Velocity velocity; |
| |
| @override |
| String toString() => 'ScaleEndDetails(velocity: $velocity)'; |
| } |
| |
| /// Signature for when the pointers in contact with the screen have established |
| /// a focal point and initial scale of 1.0. |
| typedef GestureScaleStartCallback = void Function(ScaleStartDetails details); |
| |
| /// Signature for when the pointers in contact with the screen have indicated a |
| /// new focal point and/or scale. |
| typedef GestureScaleUpdateCallback = void Function(ScaleUpdateDetails details); |
| |
| /// Signature for when the pointers are no longer in contact with the screen. |
| typedef GestureScaleEndCallback = void Function(ScaleEndDetails details); |
| |
| bool _isFlingGesture(Velocity velocity) { |
| assert(velocity != null); |
| final double speedSquared = velocity.pixelsPerSecond.distanceSquared; |
| return speedSquared > kMinFlingVelocity * kMinFlingVelocity; |
| } |
| |
| |
| /// Defines a line between two pointers on screen. |
| /// |
| /// [_LineBetweenPointers] is an abstraction of a line between two pointers in |
| /// contact with the screen. Used to track the rotation of a scale gesture. |
| class _LineBetweenPointers{ |
| |
| /// Creates a [_LineBetweenPointers]. None of the [pointerStartLocation], [pointerStartId] |
| /// [pointerEndLocation] and [pointerEndId] must be null. [pointerStartId] and [pointerEndId] |
| /// should be different. |
| _LineBetweenPointers({ |
| this.pointerStartLocation = Offset.zero, |
| this.pointerStartId = 0, |
| this.pointerEndLocation = Offset.zero, |
| this.pointerEndId = 1, |
| }) : assert(pointerStartLocation != null && pointerEndLocation != null), |
| assert(pointerStartId != null && pointerEndId != null), |
| assert(pointerStartId != pointerEndId); |
| |
| // The location and the id of the pointer that marks the start of the line. |
| final Offset pointerStartLocation; |
| final int pointerStartId; |
| |
| // The location and the id of the pointer that marks the end of the line. |
| final Offset pointerEndLocation; |
| final int pointerEndId; |
| |
| } |
| |
| |
| /// Recognizes a scale gesture. |
| /// |
| /// [ScaleGestureRecognizer] tracks the pointers in contact with the screen and |
| /// calculates their focal point, indicated scale, and rotation. When a focal |
| /// pointer is established, the recognizer calls [onStart]. As the focal point, |
| /// scale, rotation change, the recognizer calls [onUpdate]. When the pointers |
| /// are no longer in contact with the screen, the recognizer calls [onEnd]. |
| class ScaleGestureRecognizer extends OneSequenceGestureRecognizer { |
| /// Create a gesture recognizer for interactions intended for scaling content. |
| /// |
| /// {@macro flutter.gestures.gestureRecognizer.kind} |
| ScaleGestureRecognizer({ |
| Object debugOwner, |
| PointerDeviceKind kind, |
| }) : super(debugOwner: debugOwner, kind: kind); |
| |
| /// The pointers in contact with the screen have established a focal point and |
| /// initial scale of 1.0. |
| GestureScaleStartCallback onStart; |
| |
| /// The pointers in contact with the screen have indicated a new focal point |
| /// and/or scale. |
| GestureScaleUpdateCallback onUpdate; |
| |
| /// The pointers are no longer in contact with the screen. |
| GestureScaleEndCallback onEnd; |
| |
| _ScaleState _state = _ScaleState.ready; |
| |
| Matrix4 _lastTransform; |
| |
| Offset _initialFocalPoint; |
| Offset _currentFocalPoint; |
| double _initialSpan; |
| double _currentSpan; |
| double _initialHorizontalSpan; |
| double _currentHorizontalSpan; |
| double _initialVerticalSpan; |
| double _currentVerticalSpan; |
| _LineBetweenPointers _initialLine; |
| _LineBetweenPointers _currentLine; |
| Map<int, Offset> _pointerLocations; |
| List<int> _pointerQueue; // A queue to sort pointers in order of entrance |
| final Map<int, VelocityTracker> _velocityTrackers = <int, VelocityTracker>{}; |
| |
| double get _scaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0; |
| |
| double get _horizontalScaleFactor => _initialHorizontalSpan > 0.0 ? _currentHorizontalSpan / _initialHorizontalSpan : 1.0; |
| |
| double get _verticalScaleFactor => _initialVerticalSpan > 0.0 ? _currentVerticalSpan / _initialVerticalSpan : 1.0; |
| |
| double _computeRotationFactor() { |
| if (_initialLine == null || _currentLine == null) { |
| return 0.0; |
| } |
| 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; |
| } |
| |
| @override |
| void addAllowedPointer(PointerEvent event) { |
| startTrackingPointer(event.pointer, event.transform); |
| _velocityTrackers[event.pointer] = VelocityTracker(); |
| if (_state == _ScaleState.ready) { |
| _state = _ScaleState.possible; |
| _initialSpan = 0.0; |
| _currentSpan = 0.0; |
| _initialHorizontalSpan = 0.0; |
| _currentHorizontalSpan = 0.0; |
| _initialVerticalSpan = 0.0; |
| _currentVerticalSpan = 0.0; |
| _pointerLocations = <int, Offset>{}; |
| _pointerQueue = <int>[]; |
| } |
| } |
| |
| @override |
| void handleEvent(PointerEvent event) { |
| assert(_state != _ScaleState.ready); |
| bool didChangeConfiguration = false; |
| bool shouldStartIfAccepted = false; |
| if (event is PointerMoveEvent) { |
| final VelocityTracker tracker = _velocityTrackers[event.pointer]; |
| assert(tracker != null); |
| if (!event.synthesized) |
| tracker.addPosition(event.timeStamp, event.position); |
| _pointerLocations[event.pointer] = event.position; |
| shouldStartIfAccepted = true; |
| _lastTransform = event.transform; |
| } else if (event is PointerDownEvent) { |
| _pointerLocations[event.pointer] = event.position; |
| _pointerQueue.add(event.pointer); |
| didChangeConfiguration = true; |
| shouldStartIfAccepted = true; |
| _lastTransform = event.transform; |
| } else if (event is PointerUpEvent || event is PointerCancelEvent) { |
| _pointerLocations.remove(event.pointer); |
| _pointerQueue.remove(event.pointer); |
| didChangeConfiguration = true; |
| _lastTransform = event.transform; |
| } |
| |
| _updateLines(); |
| _update(); |
| |
| if (!didChangeConfiguration || _reconfigure(event.pointer)) |
| _advanceStateMachine(shouldStartIfAccepted); |
| stopTrackingIfPointerNoLongerDown(event); |
| } |
| |
| void _update() { |
| final int count = _pointerLocations.keys.length; |
| |
| // 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; |
| |
| // 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. |
| double totalDeviation = 0.0; |
| 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(); |
| } |
| _currentSpan = count > 0 ? totalDeviation / count : 0.0; |
| _currentHorizontalSpan = count > 0 ? totalHorizontalDeviation / count : 0.0; |
| _currentVerticalSpan = count > 0 ? totalVerticalDeviation / count : 0.0; |
| } |
| |
| /// Updates [_initialLine] and [_currentLine] accordingly to the situation of |
| /// the registered pointers |
| void _updateLines() { |
| final int count = _pointerLocations.keys.length; |
| assert(_pointerQueue.length >= count); |
| /// In case of just one pointer registered, reconfigure [_initialLine] |
| if (count < 2) { |
| _initialLine = _currentLine; |
| } else if (_initialLine != null && |
| _initialLine.pointerStartId == _pointerQueue[0] && |
| _initialLine.pointerEndId == _pointerQueue[1]) { |
| /// Rotation updated, set the [_currentLine] |
| _currentLine = _LineBetweenPointers( |
| pointerStartId: _pointerQueue[0], |
| pointerStartLocation: _pointerLocations[_pointerQueue[0]], |
| pointerEndId: _pointerQueue[1], |
| pointerEndLocation: _pointerLocations[_pointerQueue[1]], |
| ); |
| } else { |
| /// A new rotation process is on the way, set the [_initialLine] |
| _initialLine = _LineBetweenPointers( |
| pointerStartId: _pointerQueue[0], |
| pointerStartLocation: _pointerLocations[_pointerQueue[0]], |
| pointerEndId: _pointerQueue[1], |
| pointerEndLocation: _pointerLocations[_pointerQueue[1]], |
| ); |
| _currentLine = null; |
| } |
| } |
| |
| bool _reconfigure(int pointer) { |
| _initialFocalPoint = _currentFocalPoint; |
| _initialSpan = _currentSpan; |
| _initialLine = _currentLine; |
| _initialHorizontalSpan = _currentHorizontalSpan; |
| _initialVerticalSpan = _currentVerticalSpan; |
| if (_state == _ScaleState.started) { |
| if (onEnd != null) { |
| final VelocityTracker tracker = _velocityTrackers[pointer]; |
| assert(tracker != null); |
| |
| Velocity velocity = tracker.getVelocity(); |
| if (_isFlingGesture(velocity)) { |
| final Offset pixelsPerSecond = velocity.pixelsPerSecond; |
| if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) |
| velocity = Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity); |
| invokeCallback<void>('onEnd', () => onEnd(ScaleEndDetails(velocity: velocity))); |
| } else { |
| invokeCallback<void>('onEnd', () => onEnd(ScaleEndDetails(velocity: Velocity.zero))); |
| } |
| } |
| _state = _ScaleState.accepted; |
| return false; |
| } |
| return true; |
| } |
| |
| void _advanceStateMachine(bool shouldStartIfAccepted) { |
| if (_state == _ScaleState.ready) |
| _state = _ScaleState.possible; |
| |
| if (_state == _ScaleState.possible) { |
| final double spanDelta = (_currentSpan - _initialSpan).abs(); |
| final double focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance; |
| if (spanDelta > kScaleSlop || focalPointDelta > kPanSlop) |
| resolve(GestureDisposition.accepted); |
| } else if (_state.index >= _ScaleState.accepted.index) { |
| resolve(GestureDisposition.accepted); |
| } |
| |
| if (_state == _ScaleState.accepted && shouldStartIfAccepted) { |
| _state = _ScaleState.started; |
| _dispatchOnStartCallbackIfNeeded(); |
| } |
| |
| if (_state == _ScaleState.started && onUpdate != null) |
| invokeCallback<void>('onUpdate', () { |
| onUpdate(ScaleUpdateDetails( |
| scale: _scaleFactor, |
| horizontalScale: _horizontalScaleFactor, |
| verticalScale: _verticalScaleFactor, |
| focalPoint: _currentFocalPoint, |
| localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint), |
| rotation: _computeRotationFactor(), |
| )); |
| }); |
| } |
| |
| void _dispatchOnStartCallbackIfNeeded() { |
| assert(_state == _ScaleState.started); |
| if (onStart != null) |
| invokeCallback<void>('onStart', () { |
| onStart(ScaleStartDetails( |
| focalPoint: _currentFocalPoint, |
| localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint), |
| )); |
| }); |
| } |
| |
| @override |
| void acceptGesture(int pointer) { |
| if (_state == _ScaleState.possible) { |
| _state = _ScaleState.started; |
| _dispatchOnStartCallbackIfNeeded(); |
| } |
| } |
| |
| @override |
| void rejectGesture(int pointer) { |
| stopTrackingPointer(pointer); |
| } |
| |
| @override |
| void didStopTrackingLastPointer(int pointer) { |
| switch (_state) { |
| case _ScaleState.possible: |
| resolve(GestureDisposition.rejected); |
| break; |
| case _ScaleState.ready: |
| assert(false); // We should have not seen a pointer yet |
| break; |
| case _ScaleState.accepted: |
| break; |
| case _ScaleState.started: |
| assert(false); // We should be in the accepted state when user is done |
| break; |
| } |
| _state = _ScaleState.ready; |
| } |
| |
| @override |
| void dispose() { |
| _velocityTrackers.clear(); |
| super.dispose(); |
| } |
| |
| @override |
| String get debugDescription => 'scale'; |
| } |