diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index 916b10b49..19392588a 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -38,8 +38,9 @@ class MagnifierCore extends StatefulWidget { } class MagnifierCoreState extends State with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector { - Offset _prevViewportFocalPosition; - double _gestureStartScale; + Offset _startFocalPoint, _lastViewportFocalPosition; + double _startScale, _quickScaleLastY, _quickScaleLastDistance; + bool _doubleTap, _quickScaleMoved; AnimationController _scaleAnimationController; Animation _scaleAnimation; @@ -57,18 +58,40 @@ class MagnifierCoreState extends State with TickerProviderStateMi controller.update(position: _positionAnimation.value, source: ChangeSource.animation); } - void onScaleStart(ScaleStartDetails details) { - _gestureStartScale = scale; - _prevViewportFocalPosition = details.localFocalPoint; + void onScaleStart(ScaleStartDetails details, bool doubleTap) { + _startScale = scale; + _startFocalPoint = details.localFocalPoint; + _lastViewportFocalPosition = _startFocalPoint; + _doubleTap = doubleTap; + _quickScaleLastDistance = null; + _quickScaleLastY = _startFocalPoint.dy; + _quickScaleMoved = false; _scaleAnimationController.stop(); _positionAnimationController.stop(); } void onScaleUpdate(ScaleUpdateDetails details) { - final newScale = _gestureStartScale * details.scale; - final panPositionDelta = details.focalPoint - _prevViewportFocalPosition; - final scalePositionDelta = scaleBoundaries.viewportToStatePosition(controller, details.focalPoint) * (scale / newScale - 1); + double newScale; + if (_doubleTap) { + // quick scale, aka one finger zoom + // magic numbers from `davemorrissey/subsampling-scale-image-view` + final focalPointY = details.focalPoint.dy; + final distance = (focalPointY - _startFocalPoint.dy).abs() * 2 + 20; + _quickScaleLastDistance ??= distance; + final spanDiff = (1 - (distance / _quickScaleLastDistance)).abs() * .5; + _quickScaleMoved |= spanDiff > .03; + final factor = _quickScaleMoved ? (focalPointY > _quickScaleLastY ? (1 + spanDiff) : (1 - spanDiff)) : 1; + _quickScaleLastDistance = distance; + _quickScaleLastY = focalPointY; + newScale = scale * factor; + } else { + newScale = _startScale * details.scale; + } + final scaleFocalPoint = _doubleTap ? _startFocalPoint : details.focalPoint; + + final panPositionDelta = scaleFocalPoint - _lastViewportFocalPosition; + final scalePositionDelta = scaleBoundaries.viewportToStatePosition(controller, scaleFocalPoint) * (scale / newScale - 1); final newPosition = position + panPositionDelta + scalePositionDelta; updateScaleStateFromNewScale(newScale, ChangeSource.gesture); @@ -78,7 +101,7 @@ class MagnifierCoreState extends State with TickerProviderStateMi source: ChangeSource.gesture, ); - _prevViewportFocalPosition = details.focalPoint; + _lastViewportFocalPosition = scaleFocalPoint; } void onScaleEnd(ScaleEndDetails details) { @@ -116,7 +139,7 @@ class MagnifierCoreState extends State with TickerProviderStateMi final magnitude = details.velocity.pixelsPerSecond.distance; // animate velocity only if there is no scale change and a significant magnitude - if (_gestureStartScale / _scale == 1.0 && magnitude >= 400.0) { + if (_startScale / _scale == 1.0 && magnitude >= 400.0) { final direction = details.velocity.pixelsPerSecond / magnitude; animatePosition( _position, diff --git a/lib/widgets/common/magnifier/core/gesture_detector.dart b/lib/widgets/common/magnifier/core/gesture_detector.dart index 933c06b41..b709725ec 100644 --- a/lib/widgets/common/magnifier/core/gesture_detector.dart +++ b/lib/widgets/common/magnifier/core/gesture_detector.dart @@ -1,5 +1,4 @@ -import 'dart:math'; - +import 'package:aves/widgets/common/magnifier/core/scale_gesture_recognizer.dart'; import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; @@ -21,7 +20,7 @@ class MagnifierGestureDetector extends StatefulWidget { }) : super(key: key); final CornerHitDetector hitDetector; - final GestureScaleStartCallback onScaleStart; + final void Function(ScaleStartDetails details, bool doubleTap) onScaleStart; final GestureScaleUpdateCallback onScaleUpdate; final GestureScaleEndCallback onScaleEnd; @@ -37,7 +36,7 @@ class MagnifierGestureDetector extends StatefulWidget { } class _MagnifierGestureDetectorState extends State { - TapDownDetails doubleTapDetails; + final ValueNotifier doubleTapDetails = ValueNotifier(null); @override Widget build(BuildContext context) { @@ -65,23 +64,23 @@ class _MagnifierGestureDetectorState extends State { debugOwner: this, validateAxis: axis, touchSlopFactor: touchSlopFactor, + doubleTapDetails: doubleTapDetails, ), (instance) { - instance - ..onStart = widget.onScaleStart - ..onUpdate = widget.onScaleUpdate - ..onEnd = widget.onScaleEnd; + instance.onStart = (details) => widget.onScaleStart(details, doubleTapDetails.value != null); + instance.onUpdate = widget.onScaleUpdate; + instance.onEnd = widget.onScaleEnd; }, ); gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( () => DoubleTapGestureRecognizer(debugOwner: this), (instance) { - instance.onDoubleTapCancel = () => doubleTapDetails = null; - instance.onDoubleTapDown = (details) => doubleTapDetails = details; + instance.onDoubleTapCancel = () => doubleTapDetails.value = null; + instance.onDoubleTapDown = (details) => doubleTapDetails.value = details; instance.onDoubleTap = () { - widget.onDoubleTap(doubleTapDetails); - doubleTapDetails = null; + widget.onDoubleTap(doubleTapDetails.value); + doubleTapDetails.value = null; }; }, ); @@ -93,139 +92,3 @@ class _MagnifierGestureDetectorState extends State { ); } } - -class MagnifierGestureRecognizer extends ScaleGestureRecognizer { - MagnifierGestureRecognizer({ - this.hitDetector, - Object debugOwner, - this.validateAxis, - this.touchSlopFactor = 2, - PointerDeviceKind kind, - }) : super(debugOwner: debugOwner, kind: kind); - final CornerHitDetector hitDetector; - final List validateAxis; - final double touchSlopFactor; - - Map _pointerLocations = {}; - - Offset _initialFocalPoint; - Offset _currentFocalPoint; - double _initialSpan; - double _currentSpan; - - bool ready = true; - - @override - void addAllowedPointer(PointerEvent event) { - if (ready) { - ready = false; - _initialSpan = 0.0; - _currentSpan = 0.0; - _pointerLocations = {}; - } - super.addAllowedPointer(event); - } - - @override - void didStopTrackingLastPointer(int pointer) { - ready = true; - super.didStopTrackingLastPointer(pointer); - } - - @override - void handleEvent(PointerEvent event) { - if (validateAxis != null && validateAxis.isNotEmpty) { - var didChangeConfiguration = false; - if (event is PointerMoveEvent) { - if (!event.synthesized) { - _pointerLocations[event.pointer] = event.position; - } - } else if (event is PointerDownEvent) { - _pointerLocations[event.pointer] = event.position; - didChangeConfiguration = true; - } else if (event is PointerUpEvent || event is PointerCancelEvent) { - _pointerLocations.remove(event.pointer); - didChangeConfiguration = true; - } - - _updateDistances(); - - if (didChangeConfiguration) { - // cf super._reconfigure - _initialFocalPoint = _currentFocalPoint; - _initialSpan = _currentSpan; - } - - _decideIfWeAcceptEvent(event); - } - super.handleEvent(event); - } - - void _updateDistances() { - // cf super._update - final count = _pointerLocations.keys.length; - - // Compute the focal point - var focalPoint = Offset.zero; - for (final 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. - var totalDeviation = 0.0; - for (final pointer in _pointerLocations.keys) { - totalDeviation += (_currentFocalPoint - _pointerLocations[pointer]).distance; - } - _currentSpan = count > 0 ? totalDeviation / count : 0.0; - } - - void _decideIfWeAcceptEvent(PointerEvent event) { - if (!(event is PointerMoveEvent)) { - return; - } - - if (_pointerLocations.keys.length >= 2) { - // when there are multiple pointers, we always accept the gesture to scale - // as this is not competing with single taps or other drag gestures - acceptGesture(event.pointer); - return; - } - - final move = _initialFocalPoint - _currentFocalPoint; - var shouldMove = false; - if (validateAxis.length == 2) { - // the image is the descendant of gesture detector(s) handling drag in both directions - final shouldMoveX = validateAxis.contains(Axis.horizontal) && hitDetector.shouldMoveX(move); - final shouldMoveY = validateAxis.contains(Axis.vertical) && hitDetector.shouldMoveY(move); - if (shouldMoveX == shouldMoveY) { - // consistently can/cannot pan the image in both direction the same way - shouldMove = shouldMoveX; - } else { - // can pan the image in one direction, but should yield to an ascendant gesture detector in the other one - final d = move.direction; - // the gesture direction angle is in ]-pi, pi], cf `Offset` doc for details - final xPan = (-pi / 4 < d && d < pi / 4) || (3 / 4 * pi < d && d <= pi) || (-pi < d && d < -3 / 4 * pi); - final yPan = (pi / 4 < d && d < 3 / 4 * pi) || (-3 / 4 * pi < d && d < -pi / 4); - shouldMove = (xPan && shouldMoveX) || (yPan && shouldMoveY); - } - } else { - // the image is the descendant of a gesture detector handling drag in one direction - shouldMove = validateAxis.contains(Axis.vertical) ? hitDetector.shouldMoveY(move) : hitDetector.shouldMoveX(move); - } - if (shouldMove) { - final spanDelta = (_currentSpan - _initialSpan).abs(); - final focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance; - // warning: do not compare `focalPointDelta` to `kPanSlop` - // `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop` - // and the magnifier recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView` - // setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0` - // setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView` - if (spanDelta > kScaleSlop || focalPointDelta > kTouchSlop * touchSlopFactor) { - acceptGesture(event.pointer); - } - } - } -} diff --git a/lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart b/lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart new file mode 100644 index 000000000..37db8bbc6 --- /dev/null +++ b/lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart @@ -0,0 +1,145 @@ +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../pan/corner_hit_detector.dart'; + +class MagnifierGestureRecognizer extends ScaleGestureRecognizer { + final CornerHitDetector hitDetector; + final List validateAxis; + final double touchSlopFactor; + final ValueNotifier doubleTapDetails; + + MagnifierGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + this.hitDetector, + this.validateAxis, + this.touchSlopFactor = 2, + this.doubleTapDetails, + }) : super(debugOwner: debugOwner, kind: kind); + + Map _pointerLocations = {}; + + Offset _initialFocalPoint; + Offset _currentFocalPoint; + double _initialSpan; + double _currentSpan; + + bool ready = true; + + @override + void addAllowedPointer(PointerEvent event) { + if (ready) { + ready = false; + _initialSpan = 0.0; + _currentSpan = 0.0; + _pointerLocations = {}; + } + super.addAllowedPointer(event); + } + + @override + void didStopTrackingLastPointer(int pointer) { + ready = true; + super.didStopTrackingLastPointer(pointer); + } + + @override + void handleEvent(PointerEvent event) { + if (validateAxis != null && validateAxis.isNotEmpty) { + var didChangeConfiguration = false; + if (event is PointerMoveEvent) { + if (!event.synthesized) { + _pointerLocations[event.pointer] = event.position; + } + } else if (event is PointerDownEvent) { + _pointerLocations[event.pointer] = event.position; + didChangeConfiguration = true; + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + _pointerLocations.remove(event.pointer); + didChangeConfiguration = true; + } + + _updateDistances(); + + if (didChangeConfiguration) { + // cf super._reconfigure + _initialFocalPoint = _currentFocalPoint; + _initialSpan = _currentSpan; + } + + _decideIfWeAcceptEvent(event); + } + super.handleEvent(event); + } + + void _updateDistances() { + // cf super._update + final count = _pointerLocations.keys.length; + + // Compute the focal point + var focalPoint = Offset.zero; + for (final 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. + var totalDeviation = 0.0; + for (final pointer in _pointerLocations.keys) { + totalDeviation += (_currentFocalPoint - _pointerLocations[pointer]).distance; + } + _currentSpan = count > 0 ? totalDeviation / count : 0.0; + } + + void _decideIfWeAcceptEvent(PointerEvent event) { + if (!(event is PointerMoveEvent)) return; + + if (_pointerLocations.keys.length >= 2) { + // when there are multiple pointers, we always accept the gesture to scale + // as this is not competing with single taps or other drag gestures + acceptGesture(event.pointer); + return; + } + + final move = _initialFocalPoint - _currentFocalPoint; + var shouldMove = false; + if (validateAxis.length == 2) { + // the image is the descendant of gesture detector(s) handling drag in both directions + final shouldMoveX = validateAxis.contains(Axis.horizontal) && hitDetector.shouldMoveX(move); + final shouldMoveY = validateAxis.contains(Axis.vertical) && hitDetector.shouldMoveY(move); + if (shouldMoveX == shouldMoveY) { + // consistently can/cannot pan the image in both direction the same way + shouldMove = shouldMoveX; + } else { + // can pan the image in one direction, but should yield to an ascendant gesture detector in the other one + final d = move.direction; + // the gesture direction angle is in ]-pi, pi], cf `Offset` doc for details + final xPan = (-pi / 4 < d && d < pi / 4) || (3 / 4 * pi < d && d <= pi) || (-pi < d && d < -3 / 4 * pi); + final yPan = (pi / 4 < d && d < 3 / 4 * pi) || (-3 / 4 * pi < d && d < -pi / 4); + shouldMove = (xPan && shouldMoveX) || (yPan && shouldMoveY); + } + } else { + // the image is the descendant of a gesture detector handling drag in one direction + shouldMove = validateAxis.contains(Axis.vertical) ? hitDetector.shouldMoveY(move) : hitDetector.shouldMoveX(move); + } + + final doubleTap = doubleTapDetails?.value != null; + if (shouldMove || doubleTap) { + final spanDelta = (_currentSpan - _initialSpan).abs(); + final focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance; + // warning: do not compare `focalPointDelta` to `kPanSlop` + // `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop` + // and the magnifier recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView` + // setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0` + // setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView` + if (spanDelta > kScaleSlop || focalPointDelta > kTouchSlop * touchSlopFactor) { + acceptGesture(event.pointer); + } + } + } +}