From 44e427bef5fb094ce4e8d9add4e315900d16c2eb Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 18 Jul 2023 21:06:03 +0200 Subject: [PATCH 1/4] Basic initial implementation of desktop rotation Co-Authored-By: Ian <3901173+ibrierley@users.noreply.github.com> Co-Authored-By: Guillaume Roux --- .../flutter_map_interactive_viewer.dart | 62 ++++++++++++++++++- lib/src/gestures/map_events.dart | 1 + 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/lib/src/gestures/flutter_map_interactive_viewer.dart b/lib/src/gestures/flutter_map_interactive_viewer.dart index a955e0546..0ae4dcc7d 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'; @@ -14,6 +15,17 @@ import 'package:flutter_map/src/misc/point.dart'; import 'package:flutter_map/src/misc/private/positioned_tap_detector_2.dart'; import 'package:latlong2/latlong.dart'; +extension on LogicalKeyboardKey { + bool get isControlKey { + return switch (this) { + LogicalKeyboardKey.control => true, + LogicalKeyboardKey.controlLeft => true, + LogicalKeyboardKey.controlRight => true, + _ => false, + }; + } +} + /// Applies interactions (gestures/scroll/taps etc) to the current [MapCamera] /// via the internal [controller]. class FlutterMapInteractiveViewer extends StatefulWidget { @@ -80,6 +92,12 @@ class FlutterMapInteractiveViewerState late Animation _doubleTapZoomAnimation; late Animation _doubleTapCenterAnimation; + final ctrlPressedNotifier = ValueNotifier(false); + Offset? initialPointerDownPos; + double cursorRotation = 0; + double clickDegrees = 0; + double dragDegrees = 0; + int _tapUpCounter = 0; Timer? _doubleTapHoldMaxDelay; @@ -100,6 +118,8 @@ class FlutterMapInteractiveViewerState _doubleTapController ..addListener(_handleDoubleTapZoomAnimation) ..addStatusListener(_doubleTapZoomStatusListener); + + ServicesBinding.instance.keyboard.addHandler(keyHandler); } void _onMapStateChange() { @@ -121,10 +141,19 @@ class FlutterMapInteractiveViewerState widget.controller.removeListener(_onMapStateChange); _flingController.dispose(); _doubleTapController.dispose(); - + ctrlPressedNotifier.dispose(); + ServicesBinding.instance.keyboard.removeHandler(keyHandler); super.dispose(); } + bool keyHandler(KeyEvent event) { + if (event.logicalKey.isControlKey) { + ctrlPressedNotifier.value = + event is KeyDownEvent || event is KeyRepeatEvent; + } + return false; + } + void updateGestures( InteractionOptions oldOptions, InteractionOptions newOptions, @@ -253,11 +282,15 @@ class FlutterMapInteractiveViewerState @override Widget build(BuildContext context) { + clickDegrees = 0; + dragDegrees = 0; + return Listener( onPointerDown: _onPointerDown, onPointerUp: _onPointerUp, onPointerCancel: _onPointerCancel, onPointerHover: _onPointerHover, + onPointerMove: _onPointerMove, onPointerSignal: _onPointerSignal, child: PositionedTapDetector2( controller: _positionedTapController, @@ -283,6 +316,8 @@ class FlutterMapInteractiveViewerState void _onPointerDown(PointerDownEvent event) { ++_pointerCounter; + clickDegrees = + getCursorRotationDegrees(event.localPosition, context) - cursorRotation; if (_options.onPointerDown != null) { final latlng = _camera.offsetToCrs(event.localPosition); @@ -292,6 +327,7 @@ class FlutterMapInteractiveViewerState void _onPointerUp(PointerUpEvent event) { --_pointerCounter; + cursorRotation = dragDegrees; if (_options.onPointerUp != null) { final latlng = _camera.offsetToCrs(event.localPosition); @@ -315,6 +351,18 @@ class FlutterMapInteractiveViewerState } } + void _onPointerMove(PointerMoveEvent event) { + if (!ctrlPressedNotifier.value) return; + + widget.controller.rotate( + dragDegrees = + getCursorRotationDegrees(event.localPosition, context) - clickDegrees, + 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 && @@ -366,6 +414,14 @@ class FlutterMapInteractiveViewerState } } + // Thanks to https://stackoverflow.com/questions/48916517/javascript-click-and-drag-to-rotate + double getCursorRotationDegrees(Offset offset, BuildContext context) => + round((math.atan2(offset.dx - MediaQuery.sizeOf(context).width / 2, + offset.dy - MediaQuery.sizeOf(context).height / 2) * + (180 / pi) * + -1) + + 100); + void _closeFlingAnimationController(MapEventSource source) { _flingAnimationStarted = false; if (_flingController.isAnimating) { @@ -432,6 +488,8 @@ class FlutterMapInteractiveViewerState } void _handleScaleDragUpdate(ScaleUpdateDetails details) { + if (ctrlPressedNotifier.value) return; + const eventSource = MapEventSource.onDrag; if (InteractiveFlag.hasDrag(_interactionOptions.flags)) { 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 From 0a52a93f2a5e2d738d5d9ea0833beb5c178d227d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 19 Jul 2023 10:51:19 +0200 Subject: [PATCH 2/4] Improved `getCursorRotationDegrees` method --- .../flutter_map_interactive_viewer.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/src/gestures/flutter_map_interactive_viewer.dart b/lib/src/gestures/flutter_map_interactive_viewer.dart index 0ae4dcc7d..5f1982e0f 100644 --- a/lib/src/gestures/flutter_map_interactive_viewer.dart +++ b/lib/src/gestures/flutter_map_interactive_viewer.dart @@ -317,7 +317,7 @@ class FlutterMapInteractiveViewerState void _onPointerDown(PointerDownEvent event) { ++_pointerCounter; clickDegrees = - getCursorRotationDegrees(event.localPosition, context) - cursorRotation; + getCursorRotationDegrees(event.localPosition) - cursorRotation; if (_options.onPointerDown != null) { final latlng = _camera.offsetToCrs(event.localPosition); @@ -356,7 +356,7 @@ class FlutterMapInteractiveViewerState widget.controller.rotate( dragDegrees = - getCursorRotationDegrees(event.localPosition, context) - clickDegrees, + getCursorRotationDegrees(event.localPosition) - clickDegrees, hasGesture: true, source: MapEventSource.cursorRotation, id: null, @@ -415,12 +415,15 @@ class FlutterMapInteractiveViewerState } // Thanks to https://stackoverflow.com/questions/48916517/javascript-click-and-drag-to-rotate - double getCursorRotationDegrees(Offset offset, BuildContext context) => - round((math.atan2(offset.dx - MediaQuery.sizeOf(context).width / 2, - offset.dy - MediaQuery.sizeOf(context).height / 2) * - (180 / pi) * - -1) + - 100); + 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; From e688d71db723ccb1e08d3b7252294f02400423d3 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 19 Jul 2023 11:43:12 +0200 Subject: [PATCH 3/4] Added ability to specify custom cursor rotation trigger keyboard keys Moved `InteractionOptions.enableScrollWheel` to `InteractiveFlags.scrollWheelZoom` (with deprecation) Improved documentation of `InteractiveFlags` --- .../flutter_map_interactive_viewer.dart | 84 ++++++++++--------- lib/src/gestures/interactive_flag.dart | 28 ++++--- lib/src/map/options.dart | 23 ++++- 3 files changed, 82 insertions(+), 53 deletions(-) diff --git a/lib/src/gestures/flutter_map_interactive_viewer.dart b/lib/src/gestures/flutter_map_interactive_viewer.dart index 5f1982e0f..8ff6ee589 100644 --- a/lib/src/gestures/flutter_map_interactive_viewer.dart +++ b/lib/src/gestures/flutter_map_interactive_viewer.dart @@ -15,17 +15,6 @@ import 'package:flutter_map/src/misc/point.dart'; import 'package:flutter_map/src/misc/private/positioned_tap_detector_2.dart'; import 'package:latlong2/latlong.dart'; -extension on LogicalKeyboardKey { - bool get isControlKey { - return switch (this) { - LogicalKeyboardKey.control => true, - LogicalKeyboardKey.controlLeft => true, - LogicalKeyboardKey.controlRight => true, - _ => false, - }; - } -} - /// Applies interactions (gestures/scroll/taps etc) to the current [MapCamera] /// via the internal [controller]. class FlutterMapInteractiveViewer extends StatefulWidget { @@ -92,11 +81,16 @@ class FlutterMapInteractiveViewerState late Animation _doubleTapZoomAnimation; late Animation _doubleTapCenterAnimation; - final ctrlPressedNotifier = ValueNotifier(false); - Offset? initialPointerDownPos; - double cursorRotation = 0; - double clickDegrees = 0; - double dragDegrees = 0; + // '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; @@ -111,7 +105,7 @@ class FlutterMapInteractiveViewerState void initState() { super.initState(); widget.controller.interactiveViewerState = this; - widget.controller.addListener(_onMapStateChange); + widget.controller.addListener(onMapStateChange); _flingController ..addListener(_handleFlingAnimation) ..addStatusListener(_flingAnimationStatusListener); @@ -119,11 +113,8 @@ class FlutterMapInteractiveViewerState ..addListener(_handleDoubleTapZoomAnimation) ..addStatusListener(_doubleTapZoomStatusListener); - ServicesBinding.instance.keyboard.addHandler(keyHandler); - } - - void _onMapStateChange() { - setState(() {}); + ServicesBinding.instance.keyboard + .addHandler(keyboardRotationTriggerKeyHandler); } @override @@ -138,19 +129,22 @@ class FlutterMapInteractiveViewerState @override void dispose() { - widget.controller.removeListener(_onMapStateChange); + widget.controller.removeListener(onMapStateChange); _flingController.dispose(); _doubleTapController.dispose(); - ctrlPressedNotifier.dispose(); - ServicesBinding.instance.keyboard.removeHandler(keyHandler); + crRotationTriggered.dispose(); + ServicesBinding.instance.keyboard + .removeHandler(keyboardRotationTriggerKeyHandler); super.dispose(); } - bool keyHandler(KeyEvent event) { - if (event.logicalKey.isControlKey) { - ctrlPressedNotifier.value = - event is KeyDownEvent || event is KeyRepeatEvent; - } + 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; } @@ -215,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({ @@ -282,8 +284,8 @@ class FlutterMapInteractiveViewerState @override Widget build(BuildContext context) { - clickDegrees = 0; - dragDegrees = 0; + crClickDegrees = 0; + crDragDegrees = 0; return Listener( onPointerDown: _onPointerDown, @@ -316,8 +318,7 @@ class FlutterMapInteractiveViewerState void _onPointerDown(PointerDownEvent event) { ++_pointerCounter; - clickDegrees = - getCursorRotationDegrees(event.localPosition) - cursorRotation; + crClickDegrees = getCursorRotationDegrees(event.localPosition) - crDegrees; if (_options.onPointerDown != null) { final latlng = _camera.offsetToCrs(event.localPosition); @@ -327,7 +328,7 @@ class FlutterMapInteractiveViewerState void _onPointerUp(PointerUpEvent event) { --_pointerCounter; - cursorRotation = dragDegrees; + crDegrees = crDragDegrees; if (_options.onPointerUp != null) { final latlng = _camera.offsetToCrs(event.localPosition); @@ -352,11 +353,11 @@ class FlutterMapInteractiveViewerState } void _onPointerMove(PointerMoveEvent event) { - if (!ctrlPressedNotifier.value) return; + if (!crRotationTriggered.value) return; widget.controller.rotate( - dragDegrees = - getCursorRotationDegrees(event.localPosition) - clickDegrees, + crDragDegrees = + getCursorRotationDegrees(event.localPosition) - crClickDegrees, hasGesture: true, source: MapEventSource.cursorRotation, id: null, @@ -366,7 +367,10 @@ class FlutterMapInteractiveViewerState 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. @@ -491,7 +495,7 @@ class FlutterMapInteractiveViewerState } void _handleScaleDragUpdate(ScaleUpdateDetails details) { - if (ctrlPressedNotifier.value) return; + if (crRotationTriggered.value) return; const eventSource = MapEventSource.onDrag; diff --git a/lib/src/gestures/interactive_flag.dart b/lib/src/gestures/interactive_flag.dart index 7cb621353..60b8c6c6b 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; @@ -43,9 +52,8 @@ class InteractiveFlag { /// [InteractiveFlag.rotate] and [rightFlags]= [InteractiveFlag.rotate] | /// [InteractiveFlag.flingAnimation] returns true because both have /// [InteractiveFlag.rotate] flag - static bool hasFlag(int leftFlags, int rightFlags) { - return leftFlags & rightFlags != 0; - } + static bool hasFlag(int leftFlags, int rightFlags) => + leftFlags & rightFlags != 0; /// True if any multi-finger gesture flags are enabled. static bool hasMultiFinger(int flags) => hasFlag(flags, _multiFingerFlags); diff --git a/lib/src/map/options.dart b/lib/src/map/options.dart index f6cc5ab2a..14629fae2 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'; @@ -398,12 +399,25 @@ 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 bool Function(LogicalKeyboardKey key)? + isCursorRotationKeyboardKeyTrigger; + const InteractionOptions({ this.flags = InteractiveFlag.all, this.debugMultiFingerGestureWinner = false, @@ -418,6 +432,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 +456,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 +471,7 @@ final class InteractionOptions { pinchZoomWinGestures, pinchMoveThreshold, pinchMoveWinGestures, + // ignore: deprecated_member_use_from_same_package enableScrollWheel, scrollWheelVelocity, ); From 70bffdb5dcbd563fe21e024111d43d02c756005d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 19 Jul 2023 14:11:02 +0200 Subject: [PATCH 4/4] Resolve requested changes --- lib/src/gestures/interactive_flag.dart | 5 +++-- lib/src/map/options.dart | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/src/gestures/interactive_flag.dart b/lib/src/gestures/interactive_flag.dart index 60b8c6c6b..ef681c9ba 100644 --- a/lib/src/gestures/interactive_flag.dart +++ b/lib/src/gestures/interactive_flag.dart @@ -52,8 +52,9 @@ class InteractiveFlag { /// [InteractiveFlag.rotate] and [rightFlags]= [InteractiveFlag.rotate] | /// [InteractiveFlag.flingAnimation] returns true because both have /// [InteractiveFlag.rotate] flag - static bool hasFlag(int leftFlags, int rightFlags) => - leftFlags & rightFlags != 0; + static bool hasFlag(int leftFlags, int rightFlags) { + return leftFlags & rightFlags != 0; + } /// True if any multi-finger gesture flags are enabled. static bool hasMultiFinger(int flags) => hasFlag(flags, _multiFingerFlags); diff --git a/lib/src/map/options.dart b/lib/src/map/options.dart index 14629fae2..61a671f9f 100644 --- a/lib/src/map/options.dart +++ b/lib/src/map/options.dart @@ -35,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; @@ -415,8 +417,7 @@ final class InteractionOptions { /// /// Defaults to allowing rotation by cursor if any of the Control keys are /// pressed. - final bool Function(LogicalKeyboardKey key)? - isCursorRotationKeyboardKeyTrigger; + final IsKeyboardKeyTrigger isCursorRotationKeyboardKeyTrigger; const InteractionOptions({ this.flags = InteractiveFlag.all,