Skip to content

Commit

Permalink
Support desktop (click and drag) rotation (fleaflet#1592)
Browse files Browse the repository at this point in the history
* Basic initial implementation of desktop rotation

Co-Authored-By: Ian <[email protected]>
Co-Authored-By: Guillaume Roux <[email protected]>

* Improved `getCursorRotationDegrees` method

* Added ability to specify custom cursor rotation trigger keyboard keys
Moved `InteractionOptions.enableScrollWheel` to `InteractiveFlags.scrollWheelZoom` (with deprecation)
Improved documentation of `InteractiveFlags`

* Resolve requested changes

---------

Co-authored-by: Ian <[email protected]>
Co-authored-by: Guillaume Roux <[email protected]>
  • Loading branch information
3 people authored Jul 20, 2023
1 parent 1a17eb1 commit 9697c9d
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 18 deletions.
81 changes: 73 additions & 8 deletions lib/src/gestures/flutter_map_interactive_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,6 +81,17 @@ class FlutterMapInteractiveViewerState
late Animation<double> _doubleTapZoomAnimation;
late Animation<LatLng> _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;

Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<Type, GestureRecognizerFactory> _createGestures({
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -292,6 +328,7 @@ class FlutterMapInteractiveViewerState

void _onPointerUp(PointerUpEvent event) {
--_pointerCounter;
crDegrees = crDragDegrees;

if (_options.onPointerUp != null) {
final latlng = _camera.offsetToCrs(event.localPosition);
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -432,6 +495,8 @@ class FlutterMapInteractiveViewerState
}

void _handleScaleDragUpdate(ScaleUpdateDetails details) {
if (crRotationTriggered.value) return;

const eventSource = MapEventSource.onDrag;

if (InteractiveFlag.hasDrag(_interactionOptions.flags)) {
Expand Down
23 changes: 16 additions & 7 deletions lib/src/gestures/interactive_flag.dart
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions lib/src/gestures/map_events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum MapEventSource {
custom,
scrollWheel,
nonRotatedSizeChange,
cursorRotation,
}

/// Base event class which is emitted by MapController instance, the event
Expand Down
24 changes: 21 additions & 3 deletions lib/src/map/options.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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;

Expand All @@ -455,6 +472,7 @@ final class InteractionOptions {
pinchZoomWinGestures,
pinchMoveThreshold,
pinchMoveWinGestures,
// ignore: deprecated_member_use_from_same_package
enableScrollWheel,
scrollWheelVelocity,
);
Expand Down

0 comments on commit 9697c9d

Please sign in to comment.