Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support desktop (click and drag) rotation #1592

Merged
merged 5 commits into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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