From bc99c7396055781f3622e5d5041e30c34d13223c Mon Sep 17 00:00:00 2001 From: David Green Date: Sun, 25 Oct 2026 09:16:24 -0700 Subject: [PATCH] add pitch gestures A map whose maxPitch is above 0 can now be pitched by the user: with a two-finger vertical drag on a touch screen, and with Ctrl-drag on a desktop, which then rotates with horizontal movement and pitches with vertical movement, as in MapLibre GL JS. While pitch gestures are enabled, a two-finger gesture doesn't zoom, rotate or move the map until the map has decided whether it is a pitch gesture, so that a pitch drag doesn't also move the map. Maps that keep maxPitch at 0 behave as before. --- example/macos/Podfile | 2 +- example/macos/Podfile.lock | 25 +- .../macos/Runner.xcodeproj/project.pbxproj | 50 ++- .../xcshareddata/xcschemes/Runner.xcscheme | 21 +- example/macos/Runner/AppDelegate.swift | 6 +- lib/src/gestures/interactive_flag.dart | 14 +- lib/src/gestures/map_events.dart | 20 + lib/src/gestures/map_interactive_viewer.dart | 196 +++++++- .../map/controller/map_controller_impl.dart | 20 + .../map/options/cursor_keyboard_rotation.dart | 15 + lib/src/map/options/interaction.dart | 27 ++ lib/src/map/options/options.dart | 5 + .../max_pitch_zero_gestures_test.dart | 120 +++++ test/gestures/pitch_gestures_test.dart | 419 ++++++++++++++++++ 14 files changed, 890 insertions(+), 50 deletions(-) create mode 100644 test/gestures/max_pitch_zero_gestures_test.dart create mode 100644 test/gestures/pitch_gestures_test.dart diff --git a/example/macos/Podfile b/example/macos/Podfile index 049abe295..0c76ccf54 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 6d885cf66..1f752f4f9 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -1,35 +1,16 @@ PODS: - FlutterMacOS (1.0.0) - - location (0.0.1): - - FlutterMacOS - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - url_launcher_macos (0.0.1): - - FlutterMacOS DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - - location (from `Flutter/ephemeral/.symlinks/plugins/location/macos`) - - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral - location: - :path: Flutter/ephemeral/.symlinks/plugins/location/macos - shared_preferences_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin - url_launcher_macos: - :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - location: 7cdb0665bd6577d382b0a343acdadbcb7f964775 - shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c - url_launcher_macos: 5335912b679c073563f29d89d33d10d459f95451 + FlutterMacOS: c232990155153907050900a2e175c7773903ba4e -PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 +PODFILE CHECKSUM: 505596d150d38022472859d890f709281982e016 -COCOAPODS: 1.12.0 +COCOAPODS: 1.16.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 6aaa0831a..1b510e59a 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; B6EEBB30CE1EB3CF12811845 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 60E08453F4B4BEE6B52E478F /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -70,6 +71,7 @@ 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 5A2F6B05F5DF530D0D9858BF /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 60E08453F4B4BEE6B52E478F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; B6CE115FE0949738993DBADE /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -80,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, B6EEBB30CE1EB3CF12811845 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -131,6 +134,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -184,7 +188,6 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 3C189672917463F7A9317851 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -192,6 +195,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* example.app */; productType = "com.apple.product-type.application"; @@ -203,7 +209,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -231,6 +237,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -314,23 +323,6 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 3C189672917463F7A9317851 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -405,7 +397,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -484,7 +476,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -531,7 +523,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -628,6 +620,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index fb7259e17..8b5a3a281 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,10 +1,28 @@ + + + + + + + + + + diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift index d53ef6437..b3c176141 100644 --- a/example/macos/Runner/AppDelegate.swift +++ b/example/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/lib/src/gestures/interactive_flag.dart b/lib/src/gestures/interactive_flag.dart index f499058e2..1f7550664 100644 --- a/lib/src/gestures/interactive_flag.dart +++ b/lib/src/gestures/interactive_flag.dart @@ -25,7 +25,8 @@ abstract class InteractiveFlag { doubleTapZoom | doubleTapDragZoom | scrollWheelZoom | - rotate; + rotate | + pitch; /// Disable all gesture interactions static const int none = 0; @@ -59,8 +60,14 @@ abstract class InteractiveFlag { /// [InteractionOptions.cursorKeyboardRotationOptions]. static const int rotate = 1 << 7; + /// Enable pitching with a two-finger vertical drag, and with a vertical + /// cursor/keyboard rotation drag + /// + /// Has no effect while [MapOptions.maxPitch] is 0. + static const int pitch = 1 << 8; + /// Flags pertaining to gestures which require multiple fingers. - static const _multiFingerFlags = pinchMove | pinchZoom | rotate; + static const _multiFingerFlags = pinchMove | pinchZoom | rotate | pitch; /// Returns `true` if [leftFlags] has at least one member in [rightFlags] /// (intersection) for example [leftFlags]= [InteractiveFlag.drag] | @@ -96,6 +103,9 @@ abstract class InteractiveFlag { /// True if the [rotate] interactive flag is enabled. static bool hasRotate(int flags) => hasFlag(flags, rotate); + /// True if the [pitch] interactive flag is enabled. + static bool hasPitch(int flags) => hasFlag(flags, pitch); + /// True if the [scrollWheelZoom] interactive flag is enabled. static bool hasScrollWheelZoom(int flags) => hasFlag(flags, scrollWheelZoom); } diff --git a/lib/src/gestures/map_events.dart b/lib/src/gestures/map_events.dart index af506af23..f2759706f 100644 --- a/lib/src/gestures/map_events.dart +++ b/lib/src/gestures/map_events.dart @@ -362,6 +362,26 @@ class MapEventRotateEnd extends MapEvent { }); } +/// Event which is fired when a pitch gesture starts +@immutable +class MapEventPitchStart extends MapEvent { + /// Create a new map event that represents the start of a pitch gesture. + const MapEventPitchStart({ + required super.source, + required super.camera, + }); +} + +/// Event which is fired when a pitch gesture ends +@immutable +class MapEventPitchEnd extends MapEvent { + /// Create a new map event that represents the end of a pitch gesture. + const MapEventPitchEnd({ + required super.source, + required super.camera, + }); +} + /// Event that fires when the map widget changes size, e.g. when the app window /// gets changed in size. @immutable diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 347f8c857..98d492dd8 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -23,6 +23,10 @@ typedef _KeyboardAnimationManager = Map< Tween repeatTween, })>; +/// The state of the decision whether a multi-finger scale gesture is a pitch +/// gesture. +enum _PitchGesture { undecided, pitch, ruledOut } + /// The method signature of the builder. typedef InteractiveViewerBuilder = Widget Function( BuildContext context, @@ -85,6 +89,19 @@ class MapInteractiveViewerState extends State late Offset _focalStartLocal; late LatLng _focalStartLatLng; + // The pointers on the map, each with a start position and its current + // position. Adding or removing a pointer resets every start position to the + // current position, because adding or removing a pointer also restarts the + // scale gesture. + final _pointers = {}; + Duration _lastPointerTimeStamp = Duration.zero; + + var _pitchGesture = _PitchGesture.ruledOut; + var _pitchStarted = false; + late double _pitchAtStart; + Duration? _pitchFirstMove; + final _heldBackUpdates = []; + late final _flingController = AnimationController(vsync: this); late Animation _flingAnimation; @@ -109,6 +126,10 @@ class MapInteractiveViewerState extends State final _ckrTriggered = ValueNotifier(false); double _ckrClickDegrees = 0; double _ckrInitialDegrees = 0; + // While pitch gestures are enabled, cursor/keyboard rotation pitches and + // rotates the map as MapLibre GL JS's mouse handlers do. + var _ckrPitching = false; + Offset _ckrLastPosition = Offset.zero; int _tapUpCounter = 0; Timer? _doubleTapHoldMaxDelay; @@ -240,6 +261,11 @@ class MapInteractiveViewerState extends State widget.controller.rotateEnded(MapEventSource.interactiveFlagsChanged); } + if (_pitchStarted && !InteractiveFlag.hasPitch(newOptions.flags)) { + _pitchStarted = false; + widget.controller.pitchEnded(MapEventSource.interactiveFlagsChanged); + } + var emitMapEventMoveEnd = false; if (_pinchZoomStarted && @@ -387,13 +413,28 @@ class MapInteractiveViewerState extends State ); } + void _restartPointerTracking() { + _pointers.updateAll( + (_, pointer) => (start: pointer.position, position: pointer.position), + ); + } + void _onPointerDown(PointerDownEvent event) { ++_pointerCounter; + _pointers[event.pointer] = + (start: event.localPosition, position: event.localPosition); + _restartPointerTracking(); + _lastPointerTimeStamp = event.timeStamp; if (_ckrTriggered.value) { _ckrInitialDegrees = _camera.rotation; _ckrClickDegrees = getCursorRotationDegrees(event.localPosition); widget.controller.rotateStarted(MapEventSource.cursorKeyboardRotation); + _ckrPitching = _pitchEnabled; + if (_ckrPitching) { + _ckrLastPosition = event.localPosition; + widget.controller.pitchStarted(MapEventSource.cursorKeyboardRotation); + } } if (_options.onPointerDown != null) { @@ -404,8 +445,13 @@ class MapInteractiveViewerState extends State void _onPointerUp(PointerUpEvent event) { --_pointerCounter; + _pointers.remove(event.pointer); + _restartPointerTracking(); - if (_interactionOptions.cursorKeyboardRotationOptions.setNorthOnClick && + if (_ckrPitching) { + _endCursorPitch(); + } else if (_interactionOptions + .cursorKeyboardRotationOptions.setNorthOnClick && _ckrTriggered.value && _ckrInitialDegrees == _camera.rotation) { widget.controller.rotateRaw( @@ -423,6 +469,9 @@ class MapInteractiveViewerState extends State void _onPointerCancel(PointerCancelEvent event) { --_pointerCounter; + _pointers.remove(event.pointer); + _restartPointerTracking(); + if (_ckrPitching) _endCursorPitch(); if (_options.onPointerCancel != null) { final latlng = _camera.offsetToCrs(event.localPosition); @@ -438,12 +487,24 @@ class MapInteractiveViewerState extends State } void _onPointerMove(PointerMoveEvent event) { + final pointer = _pointers[event.pointer]; + if (pointer != null) { + _pointers[event.pointer] = + (start: pointer.start, position: event.localPosition); + } + _lastPointerTimeStamp = event.timeStamp; + if (_options.onPointerMove != null) { final latLng = _camera.offsetToCrs(event.localPosition); _options.onPointerMove!(event, latLng); } if (!_ckrTriggered.value) return; + if (_ckrPitching) { + _handleCursorPitchAndRotation(event.localPosition); + return; + } + final baseSetNorth = getCursorRotationDegrees(event.localPosition) - _ckrClickDegrees; @@ -462,6 +523,48 @@ class MapInteractiveViewerState extends State } } + void _handleCursorPitchAndRotation(Offset position) { + final previous = _ckrLastPosition; + _ckrLastPosition = position; + final center = _camera.nonRotatedSize.center(Offset.zero); + + final double rotationDelta; + if ((center.dy - previous.dy).abs() > 100) { + // The angle through which the horizontal movement alone turns the + // cursor about the center. + final from = Offset(previous.dx, position.dy) - center; + final to = position - center; + rotationDelta = _normalizeDegrees( + (to.direction - from.direction) * radians2Degrees, + ); + } else { + final turn = (position.dx - previous.dx) * 0.8; + rotationDelta = position.dy < center.dy ? turn : -turn; + } + + widget.controller + ..rotateRaw( + _camera.rotation + rotationDelta, + hasGesture: true, + source: MapEventSource.cursorKeyboardRotation, + ) + ..pitchRaw( + _camera.pitch - 0.5 * (position.dy - previous.dy), + hasGesture: true, + source: MapEventSource.cursorKeyboardRotation, + ); + } + + static double _normalizeDegrees(double degrees) => + (degrees + 180) % 360 - 180; + + void _endCursorPitch() { + _ckrPitching = false; + widget.controller + ..rotateEnded(MapEventSource.cursorKeyboardRotation) + ..pitchEnded(MapEventSource.cursorKeyboardRotation); + } + void _onPointerSignal(PointerSignalEvent pointerSignal) { // Handle mouse scroll events if the enableScrollWheel parameter is enabled if (pointerSignal is PointerScrollEvent && @@ -555,6 +658,66 @@ class MapInteractiveViewerState extends State _lastRotation = 0.0; _scaleCorrector = 0.0; _lastScale = 1.0; + + _pitchGesture = !_dragMode && _canStartPitchGesture + ? _PitchGesture.undecided + : _PitchGesture.ruledOut; + _pitchStarted = false; + _pitchAtStart = _camera.pitch; + _pitchFirstMove = null; + _heldBackUpdates.clear(); + } + + bool get _pitchEnabled => + InteractiveFlag.hasPitch(_interactionOptions.flags) && + _options.maxPitch > 0; + + bool get _canStartPitchGesture { + if (!_pitchEnabled || _pointers.length != 2) return false; + final [a, b] = _pointers.values.toList(); + final line = a.start - b.start; + return line.dy.abs() <= line.dx.abs(); + } + + static bool _isVertical(Offset vector) => vector.dy.abs() > vector.dx.abs(); + + _PitchGesture _decidePitchGesture() { + if (_pointers.length != 2) return _PitchGesture.ruledOut; + final threshold = _interactionOptions.pitchThreshold; + final [a, b] = [ + for (final pointer in _pointers.values) pointer.position - pointer.start, + ]; + final moved = [a, b] + .where( + (vector) => vector != Offset.zero && vector.distance >= threshold) + .toList(); + if (moved.isEmpty) return _PitchGesture.undecided; + if (!moved.every(_isVertical)) return _PitchGesture.ruledOut; + if (moved.length == 1) { + // GL JS's ALLOWED_SINGLE_TOUCH_TIME: the other finger can start moving + // later. + final firstMove = _pitchFirstMove ??= _lastPointerTimeStamp; + return _lastPointerTimeStamp - firstMove < + const Duration(milliseconds: 100) + ? _PitchGesture.undecided + : _PitchGesture.ruledOut; + } + return (a.dy > 0) == (b.dy > 0) + ? _PitchGesture.pitch + : _PitchGesture.ruledOut; + } + + void _handleScalePitch() { + if (!_pitchStarted) return; + final verticalMovement = _pointers.values + .map((pointer) => (pointer.position - pointer.start).dy) + .reduce((a, b) => a + b) / + _pointers.length; + widget.controller.pitchRaw( + _pitchAtStart - 0.5 * verticalMovement, + hasGesture: true, + source: MapEventSource.onMultiFinger, + ); } void _handleScaleUpdate(ScaleUpdateDetails details) { @@ -563,6 +726,28 @@ class MapInteractiveViewerState extends State return; } + switch (_pitchGesture) { + case _PitchGesture.ruledOut: + _applyScaleUpdate(details); + case _PitchGesture.undecided: + _heldBackUpdates.add(details); + _pitchGesture = _decidePitchGesture(); + if (_pitchGesture == _PitchGesture.ruledOut) { + _heldBackUpdates.forEach(_applyScaleUpdate); + _heldBackUpdates.clear(); + } else if (_pitchGesture == _PitchGesture.pitch) { + _heldBackUpdates.clear(); + _pitchStarted = true; + widget.controller + .pitchStarted(MapEventSource.multiFingerGestureStart); + _handleScalePitch(); + } + case _PitchGesture.pitch: + _handleScalePitch(); + } + } + + void _applyScaleUpdate(ScaleUpdateDetails details) { final currentRotation = details.rotation * radians2Degrees; if (_dragMode) { _handleScaleDragUpdate(details); @@ -773,6 +958,15 @@ class MapInteractiveViewerState extends State void _handleScaleEnd(ScaleEndDetails details) { _resetDoubleTapHold(); + if (_pitchGesture != _PitchGesture.ruledOut) { + if (_pitchStarted) { + _pitchStarted = false; + widget.controller.pitchEnded(MapEventSource.multiFingerEnd); + } + _heldBackUpdates.clear(); + return; + } + final eventSource = _dragMode ? MapEventSource.dragEnd : MapEventSource.multiFingerEnd; diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index e6ff00a9e..d5b9713ac 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -474,6 +474,26 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> ); } + /// Call this when a pitch gesture starts. + void pitchStarted(MapEventSource source) { + _emitMapEvent( + MapEventPitchStart( + camera: camera, + source: source, + ), + ); + } + + /// Call this when a pitch gesture ends. + void pitchEnded(MapEventSource source) { + _emitMapEvent( + MapEventPitchEnd( + camera: camera, + source: source, + ), + ); + } + /// To be called when a fling gesture starts. void flingStarted(MapEventSource source) { _emitMapEvent( diff --git a/lib/src/map/options/cursor_keyboard_rotation.dart b/lib/src/map/options/cursor_keyboard_rotation.dart index 2fd9db7a4..42e02316c 100644 --- a/lib/src/map/options/cursor_keyboard_rotation.dart +++ b/lib/src/map/options/cursor_keyboard_rotation.dart @@ -1,4 +1,5 @@ import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:meta/meta.dart'; /// See [CursorKeyboardRotationOptions.isKeyTrigger] @@ -27,6 +28,20 @@ enum CursorRotationBehaviour { /// cursor to be used to set the rotation of the map whilst a keyboard key is /// held down (as triggered by [isKeyTrigger]). /// {@endtemplate} +/// +/// While [MapOptions.maxPitch] is above 0 and the interactive flags contain +/// [InteractiveFlag.pitch], cursor/keyboard rotation behaves as in MapLibre +/// GL JS: vertical cursor movement changes the pitch by 0.5° per logical +/// pixel, and horizontal movement rotates the map about its center. When the +/// cursor is more than 100 logical pixels above or below the center, the map +/// rotates by the angle through which the movement turns the cursor about the +/// center; otherwise it rotates by 0.8° per logical pixel. [behaviour] and +/// [setNorthOnClick] have no effect in that case. +/// +/// On the web on macOS, Ctrl-click opens the browser's context menu, and the +/// map receives no cursor movement while the menu is open. In a web +/// application that sets [MapOptions.maxPitch] above 0, disable the browser's +/// context menu with `BrowserContextMenu.disableContextMenu()`. @immutable class CursorKeyboardRotationOptions { /// Whether to trigger cursor/keyboard rotation dependent on the currently diff --git a/lib/src/map/options/interaction.dart b/lib/src/map/options/interaction.dart index da28cc3e3..944e73311 100644 --- a/lib/src/map/options/interaction.dart +++ b/lib/src/map/options/interaction.dart @@ -17,6 +17,9 @@ class InteractionOptions { /// [pinchMoveThreshold] will race If multiple gestures win at the same time /// then precedence: [pinchZoomWinGestures] > [rotationWinGestures] > /// [pinchMoveWinGestures] + /// + /// The map decides whether a gesture is a pitch gesture before the race; + /// see [pitchThreshold]. final bool enableMultiFingerGestureRace; /// Rotation threshold in degree default is 20.0 Map starts to rotate when @@ -63,6 +66,23 @@ class InteractionOptions { /// gestures will take effect see [MultiFingerGesture] for custom settings final int pinchMoveWinGestures; + /// Pitch threshold in logical pixels, default is 2.0. A two-finger gesture + /// is a pitch gesture once both fingers have moved this far in the same + /// vertical direction, each more vertically than horizontally, and the line + /// between them is within 45° of horizontal. + /// + /// The map decides whether a gesture is a pitch gesture before it applies + /// the other multi-finger gestures, whether or not + /// [enableMultiFingerGestureRace] is true. Until it has decided, the map + /// doesn't zoom, rotate or move. When it decides that the gesture isn't a + /// pitch gesture, the map applies the whole gesture so far. When it decides + /// that the gesture is a pitch gesture, the map doesn't zoom, rotate or move + /// until the fingers are lifted. + /// + /// Note: only takes effect if [flags] contains [InteractiveFlag.pitch] and + /// [MapOptions.maxPitch] is above 0. + final double pitchThreshold; + /// The multipler applied to the scroll offset to calculate the zoom offset, /// when smooth zooming is disabled. /// @@ -162,6 +182,7 @@ class InteractionOptions { this.pinchMoveThreshold = 40.0, this.pinchMoveWinGestures = MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, + this.pitchThreshold = 2.0, @Deprecated( 'Prefer `SnapScrollZoomOptions.zoomRate`. See documentation on this ' 'property for more information. Will be removed in an upcoming major ' @@ -188,6 +209,10 @@ class InteractionOptions { pinchMoveThreshold >= 0.0, '`pinchMoveThreshold` must be positive', ), + assert( + pitchThreshold >= 0.0, + '`pitchThreshold` must be positive', + ), assert( flingAnimationDampingRatio > 0.0, '`flingAnimationDampingRatio` must be positive', @@ -215,6 +240,7 @@ class InteractionOptions { pinchZoomWinGestures == other.pinchZoomWinGestures && pinchMoveThreshold == other.pinchMoveThreshold && pinchMoveWinGestures == other.pinchMoveWinGestures && + pitchThreshold == other.pitchThreshold && scrollWheelVelocity == other.scrollWheelVelocity && scrollZoomOptions == other.scrollZoomOptions && doubleTapDragZoomChangeCalculator == @@ -235,6 +261,7 @@ class InteractionOptions { pinchZoomWinGestures, pinchMoveThreshold, pinchMoveWinGestures, + pitchThreshold, scrollWheelVelocity, scrollZoomOptions, doubleTapDragZoomChangeCalculator, diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/options.dart index d92ad8b2c..8ed8d408c 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -95,6 +95,11 @@ class MapOptions { /// pitched /// * the tile layer loads up to 7.5 times as many tiles at 60° as when /// unpitched + /// + /// While [maxPitch] is above 0 and the interactive flags contain + /// [InteractiveFlag.pitch], the user can pitch the map with a two-finger + /// vertical drag (see [InteractionOptions.pitchThreshold]) and with + /// cursor/keyboard rotation (see [CursorKeyboardRotationOptions]). final double maxPitch; /// The largest [maxPitch] that takes effect. Above it, the visible ground diff --git a/test/gestures/max_pitch_zero_gestures_test.dart b/test/gestures/max_pitch_zero_gestures_test.dart new file mode 100644 index 000000000..12888ba2a --- /dev/null +++ b/test/gestures/max_pitch_zero_gestures_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +// The cursor/keyboard rotation angle is measured about the center of the +// screen, so the map fills the 800 x 600 test screen. +Future _pump( + WidgetTester tester, { + CursorKeyboardRotationOptions cursorKeyboardRotationOptions = + const CursorKeyboardRotationOptions(), +}) async { + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: const LatLng(45.5, -122.6), + initialZoom: 12, + interactionOptions: InteractionOptions( + cursorKeyboardRotationOptions: cursorKeyboardRotationOptions, + ), + ), + children: const [], + ), + )); + return controller; +} + +double _normalized(double degrees) => degrees % 360; + +Future _ctrlDrag(WidgetTester tester, List positions) async { + await simulateKeyDownEvent(LogicalKeyboardKey.controlLeft); + final gesture = await tester.startGesture( + positions.first, + kind: PointerDeviceKind.mouse, + ); + for (final position in positions.skip(1)) { + await gesture.moveTo(position); + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await simulateKeyUpEvent(LogicalKeyboardKey.controlLeft); + // Advances past the double-tap timeout that a click starts. + await tester.pump(const Duration(milliseconds: 300)); +} + +void main() { + testWidgets('Ctrl-drag rotates by the change of the cursor angle', + (tester) async { + final controller = await _pump(tester); + final center = controller.camera.center; + // From below the screen center to the right of it: the angle changes from + // 180 to 90 degrees. + await _ctrlDrag(tester, const [ + Offset(400, 500), + Offset(500, 450), + Offset(600, 300), + ]); + expect(_normalized(controller.camera.rotation), closeTo(270, 1e-9)); + expect(controller.camera.center, center); + expect(controller.camera.pitch, 0); + }); + + testWidgets('Ctrl-drag with set north rotates to the cursor angle', + (tester) async { + final controller = await _pump( + tester, + cursorKeyboardRotationOptions: const CursorKeyboardRotationOptions( + behaviour: CursorRotationBehaviour.setNorth, + ), + ); + // The first movement rotates the map by the change of angle (90 - 180), + // and later movements rotate it to the cursor angle itself, which is 0 + // degrees above the center. + await _ctrlDrag(tester, const [ + Offset(400, 500), + Offset(600, 300), + Offset(400, 100), + ]); + expect(_normalized(controller.camera.rotation), closeTo(0, 1e-9)); + }); + + testWidgets('Ctrl-click sets north to the clicked angle', (tester) async { + final controller = await _pump(tester); + await _ctrlDrag(tester, const [Offset(600, 300)]); + expect(_normalized(controller.camera.rotation), closeTo(90, 1e-9)); + }); + + testWidgets('two-finger vertical drag moves the map', (tester) async { + final controller = await _pump(tester); + final initial = controller.camera; + const focalStart = Offset(400, 400); + final coordinate = initial.screenOffsetToLatLng(focalStart); + final a = await tester.startGesture(const Offset(300, 400), pointer: 1); + final b = await tester.startGesture(const Offset(500, 400), pointer: 2); + for (int i = 1; i <= 10; i++) { + await a.moveTo(Offset(300, 400 - i * 20.0)); + await b.moveTo(Offset(500, 400 - i * 20.0)); + await tester.pump(const Duration(milliseconds: 16)); + } + final camera = controller.camera; + // Each update moves the map so that the coordinate is under the focal + // point of the previous update; here that is the focal point after finger + // a's last move, 10 px below the final focal point. + expect( + (camera.latLngToScreenOffset(coordinate) - const Offset(400, 210)) + .distance, + lessThan(0.5), + ); + expect(camera.zoom, initial.zoom); + expect(camera.rotation, initial.rotation); + expect(camera.pitch, 0); + await a.up(); + await b.up(); + await tester.pumpAndSettle(); + }); +} diff --git a/test/gestures/pitch_gestures_test.dart b/test/gestures/pitch_gestures_test.dart new file mode 100644 index 000000000..09719b4e1 --- /dev/null +++ b/test/gestures/pitch_gestures_test.dart @@ -0,0 +1,419 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +Future _pump( + WidgetTester tester, { + double pitch = 0, + double maxPitch = 60, + InteractionOptions interactionOptions = const InteractionOptions(), + List? events, +}) async { + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: const LatLng(45.5, -122.6), + initialZoom: 12, + initialPitch: pitch, + maxPitch: maxPitch, + interactionOptions: interactionOptions, + onMapEvent: events?.add, + ), + children: const [], + ), + )); + events?.clear(); + return controller; +} + +/// Moves fingers that start at [starts] by [movement] in [steps] equal steps, +/// one finger after the other in each step, and doesn't lift them. +Future> _moveFingers( + WidgetTester tester, + List starts, + Offset movement, { + int steps = 10, +}) async { + final fingers = [ + for (final (i, start) in starts.indexed) + await tester.startGesture(start, pointer: i + 1), + ]; + for (int step = 1; step <= steps; step++) { + for (final (i, finger) in fingers.indexed) { + await finger.moveTo(starts[i] + movement * (step / steps)); + } + await tester.pump(const Duration(milliseconds: 16)); + } + return fingers; +} + +Future _lift(WidgetTester tester, List fingers) async { + for (final finger in fingers) { + await finger.up(); + } + await tester.pumpAndSettle(); +} + +const _sideBySide = [Offset(300, 400), Offset(500, 400)]; + +void main() { + group('InteractiveFlag.pitch', () { + test('is a new flag in all', () { + expect(InteractiveFlag.pitch, 1 << 8); + expect(InteractiveFlag.hasPitch(InteractiveFlag.all), isTrue); + expect( + InteractiveFlag.hasPitch(InteractiveFlag.all & ~InteractiveFlag.pitch), + isFalse, + ); + }); + + test('is a multi-finger flag', () { + expect(InteractiveFlag.hasMultiFinger(InteractiveFlag.pitch), isTrue); + }); + }); + + group('InteractionOptions.pitchThreshold', () { + test('defaults to 2', () { + expect(const InteractionOptions().pitchThreshold, 2); + }); + + test('is part of equality', () { + const a = InteractionOptions(pitchThreshold: 10); + const b = InteractionOptions(pitchThreshold: 10); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(const InteractionOptions())); + }); + }); + + group('two-finger pitch gesture', () { + testWidgets('pitch up', (tester) async { + final controller = await _pump(tester, pitch: 10); + final initial = controller.camera; + final fingers = + await _moveFingers(tester, _sideBySide, const Offset(0, -40)); + final camera = controller.camera; + expect(camera.pitch, closeTo(30, 1e-9)); + expect(camera.zoom, initial.zoom); + expect(camera.rotation, initial.rotation); + expect(camera.center, initial.center); + await _lift(tester, fingers); + }); + + testWidgets('pitch limited to the maximum', (tester) async { + final controller = await _pump(tester, pitch: 40, maxPitch: 45); + final fingers = + await _moveFingers(tester, _sideBySide, const Offset(0, -100)); + expect(controller.camera.pitch, 45); + await _lift(tester, fingers); + }); + + testWidgets('pitch down to 0', (tester) async { + final controller = await _pump(tester, pitch: 20); + final fingers = + await _moveFingers(tester, _sideBySide, const Offset(0, 100)); + expect(controller.camera.pitch, 0); + await _lift(tester, fingers); + }); + + testWidgets('fingers one above the other', (tester) async { + final controller = await _pump(tester, pitch: 10); + final initial = controller.camera; + // The line between the fingers is 60 degrees from horizontal. + final fingers = await _moveFingers( + tester, + const [Offset(350, 313.4), Offset(450, 486.6)], + const Offset(0, -40), + ); + expect(controller.camera.pitch, 10); + expect(controller.camera.center, isNot(initial.center)); + await _lift(tester, fingers); + }); + + testWidgets('three fingers', (tester) async { + final controller = await _pump(tester, pitch: 10); + final fingers = await _moveFingers( + tester, + const [Offset(300, 400), Offset(400, 400), Offset(500, 400)], + const Offset(0, -40), + ); + expect(controller.camera.pitch, 10); + await _lift(tester, fingers); + }); + + testWidgets('pitch threshold', (tester) async { + final controller = await _pump( + tester, + pitch: 10, + interactionOptions: const InteractionOptions(pitchThreshold: 20), + ); + final initial = controller.camera; + final fingers = + await _moveFingers(tester, _sideBySide, const Offset(0, -10)); + await _lift(tester, fingers); + expect(controller.camera, initial); + }); + }); + + group('other two-finger gestures while pitch is enabled', () { + Future pinch( + WidgetTester tester, { + required double maxPitch, + required bool race, + }) async { + final controller = await _pump( + tester, + maxPitch: maxPitch, + interactionOptions: + InteractionOptions(enableMultiFingerGestureRace: race), + ); + // Both fingers move more vertically than horizontally, in opposite + // directions, so the map rules out the pitch gesture only after both + // have moved. + final fingers = [ + await tester.startGesture(const Offset(350, 400), pointer: 1), + await tester.startGesture(const Offset(450, 400), pointer: 2), + ]; + for (int step = 1; step <= 10; step++) { + await fingers[0].moveTo(Offset(350 - step * 4.0, 400 - step * 12.0)); + await tester.pump(const Duration(milliseconds: 16)); + await fingers[1].moveTo(Offset(450 + step * 4.0, 400 + step * 12.0)); + await tester.pump(const Duration(milliseconds: 16)); + } + final camera = controller.camera; + await _lift(tester, fingers); + return camera; + } + + for (final race in [false, true]) { + testWidgets('pinch matches the pinch at maximum pitch 0, race $race', + (tester) async { + final expected = await pinch(tester, maxPitch: 0, race: race); + final actual = await pinch(tester, maxPitch: 60, race: race); + expect((actual.zoom, actual.rotation), isNot((12.0, 0.0))); + expect(actual.zoom, expected.zoom); + expect(actual.rotation, expected.rotation); + expect(actual.center, expected.center); + expect(actual.pitch, 0); + }); + } + + testWidgets('spread after a recognized pitch gesture', (tester) async { + final controller = await _pump(tester, pitch: 10); + final initial = controller.camera; + final fingers = + await _moveFingers(tester, _sideBySide, const Offset(0, -40)); + for (int step = 1; step <= 10; step++) { + await fingers[0].moveTo(Offset(300 - step * 10.0, 360)); + await fingers[1].moveTo(Offset(500 + step * 10.0, 360)); + await tester.pump(const Duration(milliseconds: 16)); + } + final camera = controller.camera; + expect(camera.pitch, closeTo(30, 1e-9)); + expect(camera.zoom, initial.zoom); + expect(camera.center, initial.center); + expect(camera.rotation, initial.rotation); + await _lift(tester, fingers); + }); + + testWidgets('gesture ends before a decision', (tester) async { + final events = []; + final controller = await _pump(tester, pitch: 10, events: events); + final initial = controller.camera; + final fingers = [ + for (final (i, start) in _sideBySide.indexed) + await tester.startGesture(start, pointer: i + 1), + ]; + await tester.pump(const Duration(milliseconds: 16)); + await _lift(tester, fingers); + // Advances past the double-tap timeout that the tap starts. + await tester.pump(const Duration(milliseconds: 300)); + expect(controller.camera, initial); + expect(events.whereType(), isEmpty); + }); + }); + + group('end of a two-finger pitch gesture', () { + testWidgets('pitch events', (tester) async { + final events = []; + final controller = await _pump(tester, pitch: 10, events: events); + final fingers = + await _moveFingers(tester, _sideBySide, const Offset(0, -40)); + await _lift(tester, fingers); + expect(controller.camera.pitch, closeTo(30, 1e-9)); + expect(events.first, isA()); + expect(events.first.source, MapEventSource.multiFingerGestureStart); + final pitchEvents = events.sublist(1, events.length - 1); + expect(pitchEvents, isNotEmpty); + expect(pitchEvents, everyElement(isA())); + expect( + pitchEvents.map((event) => event.source), + everyElement(MapEventSource.onMultiFinger), + ); + expect(pitchEvents.last.camera.pitch, closeTo(30, 1e-9)); + expect(events.last, isA()); + expect(events.last.source, MapEventSource.multiFingerEnd); + }); + + testWidgets('no fling after a pitch gesture', (tester) async { + final controller = await _pump(tester, pitch: 10); + final initial = controller.camera; + final fingers = await _moveFingers( + tester, + _sideBySide, + const Offset(0, -200), + steps: 5, + ); + final pitched = controller.camera; + expect(pitched.pitch, greaterThan(10)); + for (final finger in fingers) { + await finger.up(); + await tester.pump(const Duration(milliseconds: 16)); + } + await tester.pumpAndSettle(); + expect(controller.camera, pitched); + expect(controller.camera.center, initial.center); + }); + + testWidgets('removing the pitch flag ends the pitch gesture', + (tester) async { + final events = []; + final controller = MapController(); + Widget map(int flags) => MaterialApp( + home: FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: const LatLng(45.5, -122.6), + initialZoom: 12, + initialPitch: 10, + maxPitch: 60, + interactionOptions: InteractionOptions(flags: flags), + onMapEvent: events.add, + ), + children: const [], + ), + ); + await tester.pumpWidget(map(InteractiveFlag.all)); + final fingers = + await _moveFingers(tester, _sideBySide, const Offset(0, -20)); + expect(controller.camera.pitch, closeTo(20, 1e-9)); + events.clear(); + await tester.pumpWidget( + map(InteractiveFlag.all & ~InteractiveFlag.pitch), + ); + expect(events.single, isA()); + expect(events.single.source, MapEventSource.interactiveFlagsChanged); + for (int step = 1; step <= 10; step++) { + await fingers[0].moveTo(Offset(300, 380 - step * 2.0)); + await fingers[1].moveTo(Offset(500, 380 - step * 2.0)); + await tester.pump(const Duration(milliseconds: 16)); + } + await _lift(tester, fingers); + expect(controller.camera.pitch, closeTo(20, 1e-9)); + }); + }); + + group('Ctrl-drag pitch', () { + // The map fills the 800 x 600 test screen, so its center is (400, 300). + Future ctrlDrag(WidgetTester tester, List positions) async { + await simulateKeyDownEvent(LogicalKeyboardKey.controlLeft); + final gesture = await tester.startGesture( + positions.first, + kind: PointerDeviceKind.mouse, + ); + for (final position in positions.skip(1)) { + await gesture.moveTo(position); + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await simulateKeyUpEvent(LogicalKeyboardKey.controlLeft); + // Advances past the double-tap timeout that a click starts. + await tester.pump(const Duration(milliseconds: 300)); + } + + List path(Offset from, Offset to, {int steps = 10}) => [ + for (int i = 0; i <= steps; i++) from + (to - from) * (i / steps), + ]; + + testWidgets('Ctrl-drag up', (tester) async { + final controller = await _pump(tester, pitch: 10); + await ctrlDrag( + tester, path(const Offset(400, 500), const Offset(400, 460))); + expect(controller.camera.pitch, closeTo(30, 1e-9)); + expect(controller.camera.rotation, 0); + }); + + testWidgets('Ctrl-drag sideways below the center', (tester) async { + final controller = await _pump(tester, pitch: 10); + final center = controller.camera.center; + await ctrlDrag( + tester, path(const Offset(300, 500), const Offset(500, 500))); + // Relative to the center, the cursor moves from (-100, 200) to + // (100, 200), which turns it 53.13 degrees counterclockwise on the + // screen; the map rotates in the same direction. + expect(controller.camera.rotation % 360, closeTo(306.87, 0.01)); + expect(controller.camera.pitch, 10); + expect(controller.camera.center, center); + }); + + testWidgets('Ctrl-drag near the center', (tester) async { + final controller = await _pump(tester, pitch: 10); + await ctrlDrag(tester, const [Offset(400, 350), Offset(410, 350)]); + expect(controller.camera.rotation % 360, closeTo(352, 1e-9)); + expect(controller.camera.pitch, 10); + }); + + testWidgets('set north on click while pitch is enabled', (tester) async { + final controller = await _pump(tester, pitch: 10); + await ctrlDrag(tester, const [Offset(600, 300)]); + expect(controller.camera.rotation, 0); + }); + + testWidgets('Ctrl-drag pitch events', (tester) async { + final events = []; + await _pump(tester, pitch: 10, events: events); + await ctrlDrag( + tester, path(const Offset(400, 500), const Offset(400, 460))); + final gestureEvents = events + .where((event) => event is! MapEventTap && event is! MapEventMove) + .toList(); + expect(gestureEvents[0], isA()); + expect(gestureEvents[1], isA()); + final pitchEvents = gestureEvents.sublist(2, gestureEvents.length - 2); + expect(pitchEvents, isNotEmpty); + expect(pitchEvents, everyElement(isA())); + expect(gestureEvents[gestureEvents.length - 2], isA()); + expect(gestureEvents.last, isA()); + expect( + gestureEvents.map((event) => event.source), + everyElement(MapEventSource.cursorKeyboardRotation), + ); + }); + }); + + testWidgets('pitch start and end events', (tester) async { + final controller = MapController(); + final events = []; + await tester.pumpWidget(MaterialApp( + home: FlutterMap( + mapController: controller, + options: MapOptions(onMapEvent: events.add), + children: const [], + ), + )); + events.clear(); + (controller as MapControllerImpl) + ..pitchStarted(MapEventSource.multiFingerGestureStart) + ..pitchEnded(MapEventSource.multiFingerEnd); + expect(events, hasLength(2)); + expect(events[0], isA()); + expect(events[0].source, MapEventSource.multiFingerGestureStart); + expect(events[1], isA()); + expect(events[1].source, MapEventSource.multiFingerEnd); + }); +}