diff --git a/lib/src/gestures/flutter_map_interactive_viewer.dart b/lib/src/gestures/flutter_map_interactive_viewer.dart index 4c63fdc80..5b0987c5f 100644 --- a/lib/src/gestures/flutter_map_interactive_viewer.dart +++ b/lib/src/gestures/flutter_map_interactive_viewer.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_map/src/gestures/interactive_flag.dart'; import 'package:flutter_map/src/gestures/latlng_tween.dart'; import 'package:flutter_map/src/gestures/map_events.dart'; @@ -80,6 +81,17 @@ class FlutterMapInteractiveViewerState late Animation _doubleTapZoomAnimation; late Animation _doubleTapCenterAnimation; + // 'CR' = cursor rotation + final _defaultCRTriggerKeys = { + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight + }; + final crRotationTriggered = ValueNotifier(false); + double crDegrees = 0; + double crClickDegrees = 0; + double crDragDegrees = 0; + int _tapUpCounter = 0; Timer? _doubleTapHoldMaxDelay; @@ -93,17 +105,16 @@ class FlutterMapInteractiveViewerState void initState() { super.initState(); widget.controller.interactiveViewerState = this; - widget.controller.addListener(_onMapStateChange); + widget.controller.addListener(onMapStateChange); _flingController ..addListener(_handleFlingAnimation) ..addStatusListener(_flingAnimationStatusListener); _doubleTapController ..addListener(_handleDoubleTapZoomAnimation) ..addStatusListener(_doubleTapZoomStatusListener); - } - void _onMapStateChange() { - setState(() {}); + ServicesBinding.instance.keyboard + .addHandler(keyboardRotationTriggerKeyHandler); } @override @@ -118,13 +129,25 @@ class FlutterMapInteractiveViewerState @override void dispose() { - widget.controller.removeListener(_onMapStateChange); + widget.controller.removeListener(onMapStateChange); _flingController.dispose(); _doubleTapController.dispose(); - + crRotationTriggered.dispose(); + ServicesBinding.instance.keyboard + .removeHandler(keyboardRotationTriggerKeyHandler); super.dispose(); } + void onMapStateChange() => setState(() {}); + + bool keyboardRotationTriggerKeyHandler(KeyEvent event) { + crRotationTriggered.value = + (event is KeyRepeatEvent || event is KeyDownEvent) && + (_interactionOptions.isCursorRotationKeyboardKeyTrigger ?? + (key) => _defaultCRTriggerKeys.contains(key))(event.logicalKey); + return false; + } + void updateGestures( InteractionOptions oldOptions, InteractionOptions newOptions, @@ -186,6 +209,14 @@ class FlutterMapInteractiveViewerState if (emitMapEventMoveEnd) { widget.controller.moveEnded(MapEventSource.interactiveFlagsChanged); } + + if (oldOptions.isCursorRotationKeyboardKeyTrigger != + newOptions.isCursorRotationKeyboardKeyTrigger) { + ServicesBinding.instance.keyboard + .removeHandler(keyboardRotationTriggerKeyHandler); + ServicesBinding.instance.keyboard + .addHandler(keyboardRotationTriggerKeyHandler); + } } Map _createGestures({ @@ -253,11 +284,15 @@ class FlutterMapInteractiveViewerState @override Widget build(BuildContext context) { + crClickDegrees = 0; + crDragDegrees = 0; + return Listener( onPointerDown: _onPointerDown, onPointerUp: _onPointerUp, onPointerCancel: _onPointerCancel, onPointerHover: _onPointerHover, + onPointerMove: _onPointerMove, onPointerSignal: _onPointerSignal, child: PositionedTapDetector2( controller: _positionedTapController, @@ -283,6 +318,7 @@ class FlutterMapInteractiveViewerState void _onPointerDown(PointerDownEvent event) { ++_pointerCounter; + crClickDegrees = getCursorRotationDegrees(event.localPosition) - crDegrees; if (_options.onPointerDown != null) { final latlng = _camera.offsetToCrs(event.localPosition); @@ -292,6 +328,7 @@ class FlutterMapInteractiveViewerState void _onPointerUp(PointerUpEvent event) { --_pointerCounter; + crDegrees = crDragDegrees; if (_options.onPointerUp != null) { final latlng = _camera.offsetToCrs(event.localPosition); @@ -315,10 +352,25 @@ class FlutterMapInteractiveViewerState } } + void _onPointerMove(PointerMoveEvent event) { + if (!crRotationTriggered.value) return; + + widget.controller.rotate( + crDragDegrees = + getCursorRotationDegrees(event.localPosition) - crClickDegrees, + hasGesture: true, + source: MapEventSource.cursorRotation, + id: null, + ); + } + void _onPointerSignal(PointerSignalEvent pointerSignal) { // Handle mouse scroll events if the enableScrollWheel parameter is enabled if (pointerSignal is PointerScrollEvent && - _interactionOptions.enableScrollWheel && + (InteractiveFlag.hasFlag( + _interactionOptions.flags, InteractiveFlag.scrollWheelZoom) || + // ignore: deprecated_member_use_from_same_package + _interactionOptions.enableScrollWheel) && pointerSignal.scrollDelta.dy != 0) { // Prevent scrolling of parent/child widgets simultaneously. See // [PointerSignalResolver] documentation for more information. @@ -366,6 +418,17 @@ class FlutterMapInteractiveViewerState } } + // Thanks to https://stackoverflow.com/questions/48916517/javascript-click-and-drag-to-rotate + double getCursorRotationDegrees(Offset offset) { + const correctionTerm = 180; // North = cursor + + final size = MediaQuery.sizeOf(context); + return (-math.atan2( + offset.dx - size.width / 2, offset.dy - size.height / 2) * + (180 / math.pi)) + + correctionTerm; + } + void _closeFlingAnimationController(MapEventSource source) { _flingAnimationStarted = false; if (_flingController.isAnimating) { @@ -432,6 +495,8 @@ class FlutterMapInteractiveViewerState } void _handleScaleDragUpdate(ScaleUpdateDetails details) { + if (crRotationTriggered.value) return; + const eventSource = MapEventSource.onDrag; if (InteractiveFlag.hasDrag(_interactionOptions.flags)) { diff --git a/lib/src/gestures/interactive_flag.dart b/lib/src/gestures/interactive_flag.dart index 7cb621353..ef681c9ba 100644 --- a/lib/src/gestures/interactive_flag.dart +++ b/lib/src/gestures/interactive_flag.dart @@ -1,3 +1,5 @@ +import 'package:flutter_map/src/map/options.dart'; + /// Use [InteractiveFlag] to disable / enable certain events Use /// [InteractiveFlag.all] to enable all events, use [InteractiveFlag.none] to /// disable all events @@ -13,27 +15,34 @@ /// ~[InteractiveFlag.doubleTapZoom] class InteractiveFlag { const InteractiveFlag._(); + static const int all = drag | flingAnimation | pinchMove | pinchZoom | doubleTapZoom | rotate; static const int none = 0; - // Enable move with one finger. + /// Enable panning with a single finger or cursor static const int drag = 1 << 0; - // Enable fling animation when drag or pinchMove have enough Fling Velocity. + /// Enable fling animation after panning if velocity is great enough. static const int flingAnimation = 1 << 1; - // Enable move with two or more fingers. + /// Enable panning with multiple fingers static const int pinchMove = 1 << 2; - // Enable pinch zoom. + /// Enable zooming with a multi-finger pinch gesture static const int pinchZoom = 1 << 3; - // Enable double tap zoom animation. + /// Enable zooming with a single-finger double tap gesture static const int doubleTapZoom = 1 << 4; - /// Enable map rotate. - static const int rotate = 1 << 5; + /// Enable zooming with a mouse scroll wheel + static const int scrollWheelZoom = 1 << 5; + + /// Enable rotation with two-finger twist gesture + /// + /// For controlling rotation where a keyboard/cursor combination is used, see + /// [InteractionOptions.isCursorRotationKeyboardKeyTrigger]. + static const int rotate = 1 << 6; /// Flags pertaining to gestures which require multiple fingers. static const _multiFingerFlags = pinchMove | pinchZoom | rotate; diff --git a/lib/src/gestures/map_events.dart b/lib/src/gestures/map_events.dart index a943ee7a2..c08426d65 100644 --- a/lib/src/gestures/map_events.dart +++ b/lib/src/gestures/map_events.dart @@ -23,6 +23,7 @@ enum MapEventSource { custom, scrollWheel, nonRotatedSizeChange, + cursorRotation, } /// Base event class which is emitted by MapController instance, the event diff --git a/lib/src/map/options.dart b/lib/src/map/options.dart index f6cc5ab2a..61a671f9f 100644 --- a/lib/src/map/options.dart +++ b/lib/src/map/options.dart @@ -1,4 +1,5 @@ import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/src/geo/crs.dart'; import 'package:flutter_map/src/geo/latlng_bounds.dart'; @@ -34,6 +35,8 @@ typedef PointerHoverCallback = void Function( LatLng point, ); +typedef IsKeyboardKeyTrigger = bool Function(LogicalKeyboardKey key)?; + class MapOptions { /// The Coordinate Reference System, defaults to [Epsg3857]. final Crs crs; @@ -398,12 +401,24 @@ final class InteractionOptions { /// gestures will take effect see [MultiFingerGesture] for custom settings final int pinchMoveWinGestures; - /// If true then the map will scroll when the user uses the scroll wheel on - /// his mouse. This is supported on web and desktop, but might also work well - /// on Android. A [Listener] is used to capture the onPointerSignal events. + @Deprecated( + 'Prefer `flags.scrollWheelZoom`. ' + 'This property was moved as it better suited being an `InteractiveFlag`. ' + 'This property is deprecated since v6.', + ) final bool enableScrollWheel; + final double scrollWheelVelocity; + /// Whether to allow rotation by moving the cursor dependent on the currently + /// pressed keyboard [LogicalKeyboardKey] + /// + /// Fix to returning `false` to disable cursor/keyboard rotation. + /// + /// Defaults to allowing rotation by cursor if any of the Control keys are + /// pressed. + final IsKeyboardKeyTrigger isCursorRotationKeyboardKeyTrigger; + const InteractionOptions({ this.flags = InteractiveFlag.all, this.debugMultiFingerGestureWinner = false, @@ -418,6 +433,7 @@ final class InteractionOptions { MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, this.enableScrollWheel = true, this.scrollWheelVelocity = 0.005, + this.isCursorRotationKeyboardKeyTrigger, }) : assert(rotationThreshold >= 0.0), assert(pinchZoomThreshold >= 0.0), assert(pinchMoveThreshold >= 0.0); @@ -441,6 +457,7 @@ final class InteractionOptions { pinchZoomWinGestures == other.pinchZoomWinGestures && pinchMoveThreshold == other.pinchMoveThreshold && pinchMoveWinGestures == other.pinchMoveWinGestures && + // ignore: deprecated_member_use_from_same_package enableScrollWheel == other.enableScrollWheel && scrollWheelVelocity == other.scrollWheelVelocity; @@ -455,6 +472,7 @@ final class InteractionOptions { pinchZoomWinGestures, pinchMoveThreshold, pinchMoveWinGestures, + // ignore: deprecated_member_use_from_same_package enableScrollWheel, scrollWheelVelocity, );