diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 252080994..b9b16c5ae 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -64,6 +64,7 @@ class MapInteractiveViewerState extends State bool _dragMode = false; int _gestureWinner = MultiFingerGesture.none; int _pointerCounter = 0; + PointerDeviceKind? _lastPointerKind; bool _isListeningForInterruptions = false; var _rotationStarted = false; @@ -372,6 +373,7 @@ class MapInteractiveViewerState extends State void _onPointerDown(PointerDownEvent event) { ++_pointerCounter; + _lastPointerKind = event.kind; if (_ckrTriggered.value) { _ckrInitialDegrees = _camera.rotation; @@ -808,7 +810,16 @@ class MapInteractiveViewerState extends State flingOffset: flingOffset, // `magnitude` is checked to be non-zero above, so this is always a // finite, non-zero-length direction and is a safe final fallback. - velocityDirection: details.velocity.pixelsPerSecond / magnitude, + // Negated so that it shares the convention of the tracked offsets, + // which run opposite to the pointer motion. + velocityDirection: -details.velocity.pixelsPerSecond / magnitude, + // On touch devices the pointer cannot leave the window mid-drag (the + // case the tracked offsets handle better than the velocity, see + // #2158), and the velocity - fitted over multiple recent samples - is + // far more stable than the final tracked segment: deriving the + // direction from the single last, noisy pointer segment sends fast + // swipes off-axis, so repeated flicks visibly zigzag. + preferVelocityDirection: _lastPointerKind == PointerDeviceKind.touch, ); final distance = (Offset.zero & _camera.nonRotatedSize).shortestSide; @@ -830,8 +841,19 @@ class MapInteractiveViewerState extends State /// Calculates the direction a fling gesture should continue in. /// - /// Prefers the direction of the final tracked pointer segment, falling - /// back to the overall drag direction. If both [finalSegment] and + /// All three candidate vectors must share the same convention: the + /// direction the tracked pointer offsets travel in, i.e. opposite to the + /// pointer motion. + /// + /// With [preferVelocityDirection] the (already normalized) + /// [velocityDirection] is used directly: the velocity is fitted over + /// multiple recent samples and is therefore much more stable than the + /// final tracked segment. This is the right choice for touch input, where + /// the pointer cannot leave the window mid-drag. + /// + /// Otherwise prefers the direction of the final tracked pointer segment, + /// falling back to the overall drag direction - which handles the pointer + /// leaving the window on web/desktop (#2158). If both [finalSegment] and /// [flingOffset] have zero length - which can happen even when the /// gesture recognizer reports a velocity above the fling threshold, since /// the velocity is fitted over multiple recent samples and is not @@ -843,7 +865,10 @@ class MapInteractiveViewerState extends State required Offset finalSegment, required Offset flingOffset, required Offset velocityDirection, + required bool preferVelocityDirection, }) { + if (preferVelocityDirection) return velocityDirection; + final finalSegmentDistance = finalSegment.distance; if (finalSegmentDistance > 0) return finalSegment / finalSegmentDistance; diff --git a/test/gestures/map_interactive_viewer_test.dart b/test/gestures/map_interactive_viewer_test.dart index b7ee28728..549a4a1ec 100644 --- a/test/gestures/map_interactive_viewer_test.dart +++ b/test/gestures/map_interactive_viewer_test.dart @@ -3,11 +3,30 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('MapInteractiveViewerState.flingDirection', () { + test( + 'uses the velocity direction when preferred, even when the final ' + 'segment has non-zero length (touch input: the velocity is fitted ' + 'over multiple recent samples, while the single last pointer segment ' + 'is noisy on fast swipes and sends repeated flicks zigzagging ' + 'off-axis)', + () { + final direction = MapInteractiveViewerState.flingDirection( + finalSegment: const Offset(10, 2), + flingOffset: const Offset(100, 0), + velocityDirection: const Offset(0, 1), + preferVelocityDirection: true, + ); + + expect(direction, const Offset(0, 1)); + }, + ); + test('uses the final segment direction when it has non-zero length', () { final direction = MapInteractiveViewerState.flingDirection( finalSegment: const Offset(10, 0), flingOffset: const Offset(100, 0), velocityDirection: const Offset(0, 1), + preferVelocityDirection: false, ); expect(direction, const Offset(1, 0)); @@ -21,6 +40,7 @@ void main() { finalSegment: Offset.zero, flingOffset: const Offset(0, -50), velocityDirection: const Offset(1, 0), + preferVelocityDirection: false, ); expect(direction, const Offset(0, -1)); @@ -38,6 +58,7 @@ void main() { finalSegment: Offset.zero, flingOffset: Offset.zero, velocityDirection: const Offset(0.6, 0.8), + preferVelocityDirection: false, ); expect(direction, const Offset(0.6, 0.8));