From 3ee9d799d2a252c50bacc23d5175f5eebd7004e3 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 19 Nov 2024 12:28:02 +0000 Subject: [PATCH 01/19] Added keyboard controls for map gestures --- example/lib/pages/home.dart | 8 + lib/flutter_map.dart | 1 + lib/src/gestures/map_events.dart | 4 + lib/src/gestures/map_interactive_viewer.dart | 204 ++++++++++++++++-- .../map/controller/map_controller_impl.dart | 1 + lib/src/map/options/interaction.dart | 10 + lib/src/map/options/keyboard.dart | 106 +++++++++ 7 files changed, 311 insertions(+), 23 deletions(-) create mode 100644 lib/src/map/options/keyboard.dart diff --git a/example/lib/pages/home.dart b/example/lib/pages/home.dart index a63062ff6..615094c9c 100644 --- a/example/lib/pages/home.dart +++ b/example/lib/pages/home.dart @@ -42,6 +42,14 @@ class _HomePageState extends State { const LatLng(90, 180), ), ), + interactionOptions: InteractionOptions( + keyboardOptions: KeyboardOptions( + enableArrowKeysPanning: true, + enableQERotating: true, + enableRFZooming: true, + enableWASDPanning: true, + ), + ), ), children: [ openStreetMapTileLayer, diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index f7ad8b96d..392a75893 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -57,6 +57,7 @@ export 'package:flutter_map/src/map/controller/map_controller.dart'; export 'package:flutter_map/src/map/controller/map_controller_impl.dart'; export 'package:flutter_map/src/map/options/cursor_keyboard_rotation.dart'; export 'package:flutter_map/src/map/options/interaction.dart'; +export 'package:flutter_map/src/map/options/keyboard.dart'; export 'package:flutter_map/src/map/options/options.dart'; export 'package:flutter_map/src/map/widget.dart'; export 'package:flutter_map/src/misc/bounds.dart'; diff --git a/lib/src/gestures/map_events.dart b/lib/src/gestures/map_events.dart index 2cad349bf..80f3da59d 100644 --- a/lib/src/gestures/map_events.dart +++ b/lib/src/gestures/map_events.dart @@ -66,6 +66,9 @@ enum MapEventSource { /// The [MapEvent] is caused by a CTRL + drag rotation gesture. cursorKeyboardRotation, + + /// The [MapEvent] is caused by an arrow key on the keyboard panning the map. + keyboard, } /// Base event class which is emitted by MapController instance, the event @@ -131,6 +134,7 @@ abstract class MapEventWithMove extends MapEvent { MapEventSource.onDrag || MapEventSource.onMultiFinger || MapEventSource.mapController || + MapEventSource.keyboard || MapEventSource.custom => MapEventMove( id: id, diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index ebe756de5..affeba7cb 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:math'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; @@ -91,6 +92,12 @@ class MapInteractiveViewerState extends State int _tapUpCounter = 0; Timer? _doubleTapHoldMaxDelay; + late final _keyboardListenerFocusNode = FocusNode(); + int _keyboardPanEventCounter = 0; + int _keyboardRotateEventCounter = 0; + int _keyboardZoomEventCounter = 0; + final _keyboardPanKeyDownSet = {}; + MapCamera get _camera => widget.controller.camera; MapOptions get _options => widget.controller.options; @@ -133,6 +140,8 @@ class MapInteractiveViewerState extends State ServicesBinding.instance.keyboard .removeHandler(cursorKeyboardRotationTriggerHandler); + _keyboardListenerFocusNode.dispose(); + super.dispose(); } @@ -286,35 +295,184 @@ class MapInteractiveViewerState extends State @override Widget build(BuildContext context) { - return Listener( - onPointerDown: _onPointerDown, - onPointerUp: _onPointerUp, - onPointerCancel: _onPointerCancel, - onPointerHover: _onPointerHover, - onPointerMove: _onPointerMove, - onPointerSignal: _onPointerSignal, - child: PositionedTapDetector2( - controller: _positionedTapController, - onTap: _handleTap, - onSecondaryTap: _handleSecondaryTap, - onLongPress: _handleLongPress, - onDoubleTap: _handleDoubleTap, - doubleTapDelay: - InteractiveFlag.hasDoubleTapZoom(_interactionOptions.flags) - ? null - : Duration.zero, - child: RawGestureDetector( - gestures: _gestures, - child: widget.builder( - context, - widget.controller.options, - widget.controller.camera, + return KeyboardListener( + focusNode: _keyboardListenerFocusNode, + onKeyEvent: _onKeyEvent, + child: Listener( + onPointerDown: _onPointerDown, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerCancel, + onPointerHover: _onPointerHover, + onPointerMove: _onPointerMove, + onPointerSignal: _onPointerSignal, + child: PositionedTapDetector2( + controller: _positionedTapController, + onTap: _handleTap, + onSecondaryTap: _handleSecondaryTap, + onLongPress: _handleLongPress, + onDoubleTap: _handleDoubleTap, + doubleTapDelay: + InteractiveFlag.hasDoubleTapZoom(_interactionOptions.flags) + ? null + : Duration.zero, + child: RawGestureDetector( + gestures: _gestures, + child: widget.builder( + context, + widget.controller.options, + widget.controller.camera, + ), ), ), ), ); } + void _onKeyEvent(KeyEvent evt) { + late final arrowKeysGate = + _options.interactionOptions.keyboardOptions.enableArrowKeysPanning + ? (evt.logicalKey != LogicalKeyboardKey.arrowLeft && + evt.logicalKey != LogicalKeyboardKey.arrowRight && + evt.logicalKey != LogicalKeyboardKey.arrowUp && + evt.logicalKey != LogicalKeyboardKey.arrowDown) + : true; + late final wasdKeysGate = + _options.interactionOptions.keyboardOptions.enableWASDPanning + ? (evt.logicalKey != LogicalKeyboardKey.keyW && + evt.logicalKey != LogicalKeyboardKey.keyA && + evt.logicalKey != LogicalKeyboardKey.keyS && + evt.logicalKey != LogicalKeyboardKey.keyD) + : true; + late final qeKeysGate = + _options.interactionOptions.keyboardOptions.enableQERotating + ? (evt.logicalKey != LogicalKeyboardKey.keyQ && + evt.logicalKey != LogicalKeyboardKey.keyE) + : true; + late final rfKeysGate = + _options.interactionOptions.keyboardOptions.enableRFZooming + ? (evt.logicalKey != LogicalKeyboardKey.keyR && + evt.logicalKey != LogicalKeyboardKey.keyF) + : true; + + if (arrowKeysGate && wasdKeysGate && qeKeysGate && rfKeysGate) return; + + late final arrowKeys = + _options.interactionOptions.keyboardOptions.enableArrowKeysPanning && + (evt.logicalKey == LogicalKeyboardKey.arrowLeft || + evt.logicalKey == LogicalKeyboardKey.arrowRight || + evt.logicalKey == LogicalKeyboardKey.arrowUp || + evt.logicalKey == LogicalKeyboardKey.arrowDown); + late final wasdKeys = + _options.interactionOptions.keyboardOptions.enableWASDPanning && + (evt.logicalKey == LogicalKeyboardKey.keyW || + evt.logicalKey == LogicalKeyboardKey.keyA || + evt.logicalKey == LogicalKeyboardKey.keyS || + evt.logicalKey == LogicalKeyboardKey.keyD); + late final qeKeys = + _options.interactionOptions.keyboardOptions.enableQERotating && + (evt.logicalKey == LogicalKeyboardKey.keyQ || + evt.logicalKey == LogicalKeyboardKey.keyE); + late final rfKeys = + _options.interactionOptions.keyboardOptions.enableRFZooming && + (evt.logicalKey == LogicalKeyboardKey.keyR || + evt.logicalKey == LogicalKeyboardKey.keyF); + + if (evt is KeyDownEvent) { + if (arrowKeys || wasdKeys) { + if (_keyboardPanKeyDownSet.isEmpty) { + _keyboardPanEventCounter = 0; + _closeFlingAnimationController(MapEventSource.keyboard); + _closeDoubleTapController(MapEventSource.keyboard); + } + _keyboardPanKeyDownSet.add(evt.logicalKey); + } + if (qeKeys) { + _keyboardRotateEventCounter = 0; + _closeFlingAnimationController(MapEventSource.keyboard); + _closeDoubleTapController(MapEventSource.keyboard); + } + if (rfKeys) { + _keyboardZoomEventCounter = 0; + _closeFlingAnimationController(MapEventSource.keyboard); + _closeDoubleTapController(MapEventSource.keyboard); + } + } + if (evt is KeyUpEvent) { + if (arrowKeys || wasdKeys) { + _keyboardPanKeyDownSet.remove(evt.logicalKey); + } + return; + } + + if (arrowKeys || wasdKeys) _keyboardPanEventCounter++; + if (qeKeys) _keyboardRotateEventCounter++; + if (rfKeys) _keyboardZoomEventCounter++; + + final panSpeed = _options + .interactionOptions.keyboardOptions.panSpeedCalculator + ?.call(_keyboardPanEventCounter) ?? + KeyboardOptions.defaultPanSpeedCalculator(_keyboardPanEventCounter); + var newCenter = _camera.latLngToScreenPoint(_camera.center); + for (final key in _keyboardPanKeyDownSet) { + newCenter = newCenter + + switch (key) { + LogicalKeyboardKey.arrowLeft || + LogicalKeyboardKey.keyA => + Point(-panSpeed, 0), + LogicalKeyboardKey.arrowRight || + LogicalKeyboardKey.keyD => + Point(panSpeed, 0), + LogicalKeyboardKey.arrowUp || + LogicalKeyboardKey.keyW => + Point(0, -panSpeed), + LogicalKeyboardKey.arrowDown || + LogicalKeyboardKey.keyS => + Point(0, panSpeed), + _ => throw StateError( + '`_keyboardPanKeyDownSet` should only contain arrow & WASD keys', + ), + }; + } + + final rotateSpeed = _options + .interactionOptions.keyboardOptions.rotateSpeedCalculator + ?.call(_keyboardRotateEventCounter) ?? + KeyboardOptions.defaultRotateSpeedCalculator( + _keyboardRotateEventCounter); + var newRotation = _camera.rotation; + if (qeKeys) { + if (evt.logicalKey == LogicalKeyboardKey.keyQ) { + newRotation -= rotateSpeed; + } + if (evt.logicalKey == LogicalKeyboardKey.keyE) { + newRotation += rotateSpeed; + } + } + + final zoomSpeed = _options + .interactionOptions.keyboardOptions.zoomSpeedCalculator + ?.call(_keyboardZoomEventCounter) ?? + KeyboardOptions.defaultZoomSpeedCalculator(_keyboardZoomEventCounter); + var newZoom = _camera.zoom; + if (rfKeys) { + if (evt.logicalKey == LogicalKeyboardKey.keyR) { + newZoom += zoomSpeed; + } + if (evt.logicalKey == LogicalKeyboardKey.keyF) { + newZoom -= zoomSpeed; + } + } + + widget.controller.moveAndRotateRaw( + _camera.pointToLatLng(newCenter), + newZoom, + newRotation % 360, + offset: Offset.zero, + hasGesture: true, + source: MapEventSource.keyboard, + ); + } + void _onPointerDown(PointerDownEvent event) { ++_pointerCounter; diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index e1480c1e4..ecc66aa60 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -183,6 +183,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> source: source, id: id, ); + //! PREVENTS TILE LOADING IF SOURCE NOT CONFIGURED IN CONSTRUCTOR if (movementEvent != null) _emitMapEvent(movementEvent); options.onPositionChanged?.call(newCamera, hasGesture); diff --git a/lib/src/map/options/interaction.dart b/lib/src/map/options/interaction.dart index f80038091..7885a2725 100644 --- a/lib/src/map/options/interaction.dart +++ b/lib/src/map/options/interaction.dart @@ -1,4 +1,5 @@ import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/map/options/keyboard.dart'; import 'package:meta/meta.dart'; /// All interactive options for [FlutterMap] @@ -81,6 +82,14 @@ final class InteractionOptions { /// [CursorKeyboardRotationOptions.disabled] constructor. final CursorKeyboardRotationOptions cursorKeyboardRotationOptions; + /// Options to configure how keyboard keys may be used to control the map + /// + /// See [CursorKeyboardRotationOptions] for options to control the keyboard + /// and mouse cursor being used together to rotate the map. + /// + /// By default, keyboard movement using the arrow keys is enabled. + final KeyboardOptions keyboardOptions; + /// Create a new [InteractionOptions] instance to be used /// in [MapOptions.interactionOptions]. const InteractionOptions({ @@ -97,6 +106,7 @@ final class InteractionOptions { MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, this.scrollWheelVelocity = 0.005, this.cursorKeyboardRotationOptions = const CursorKeyboardRotationOptions(), + this.keyboardOptions = const KeyboardOptions(), }) : assert( rotationThreshold >= 0.0, 'rotationThreshold needs to be a positive value', diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart new file mode 100644 index 000000000..ba13aad36 --- /dev/null +++ b/lib/src/map/options/keyboard.dart @@ -0,0 +1,106 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:meta/meta.dart'; + +/// A callback function which takes as input the number of times the concerned +/// keyboard key has been pressed down & repeated ([KeyDownEvent] & +/// [KeyRepeatEvent]) and outputs the transformation that should be applied +/// +/// See the specific field in [KeyboardOptions] for the specific output meaning. +typedef KeyboardEffectSpeedCalculator = double Function(int repetitionCounter); + +/// Options to configure how keyboard keys may be used to control the map +/// +/// See [CursorKeyboardRotationOptions] for options to control the keyboard and +/// mouse cursor being used together to rotate the map. +@immutable +class KeyboardOptions { + /// Whether to allow arrow keys to pan the map (in their respective directions) + /// + /// This is enabled by default. + final bool enableArrowKeysPanning; + + /// Whether to allow the W, A, S, D keys to pan the map (in the directions + /// UP, LEFT, DOWN, RIGHT respectively) + final bool enableWASDPanning; + + /// Whether to allow the Q & E keys to rotate the map (Q rotates COUNTER- + /// CLOCKWISE, E rotates CLOCKWISE) + final bool enableQERotating; + + /// Whether to allow the R & F keys to zoom the map (R zooms IN (increases + /// zoom level), F zooms OUT (decreases zoom level)) + final bool enableRFZooming; + + /// Calculates the transformation to apply to the camera's position, where + /// the output is in logical pixels (the direction is automatically handled) + /// + /// See [KeyboardEffectSpeedCalculator] for information. + /// + /// Defaults to [defaultPanSpeedCalculator]. + final KeyboardEffectSpeedCalculator? panSpeedCalculator; + + /// Calculates the transformation to apply to the camera's position, where + /// the output is in zoom levels (the direction is automatically handled) + /// + /// See [KeyboardEffectSpeedCalculator] for information. + /// + /// Defaults to [defaultZoomSpeedCalculator]. + final KeyboardEffectSpeedCalculator? zoomSpeedCalculator; + + /// Calculates the transformation to apply to the camera's position, where + /// the output is in degrees (the direction is automatically handled) + /// + /// See [KeyboardEffectSpeedCalculator] for information. + /// + /// Defaults to [defaultRotateSpeedCalculator]. + final KeyboardEffectSpeedCalculator? rotateSpeedCalculator; + + /// Create options which specify how the map may be controlled by keyboard + /// keys + /// + /// Only [enableArrowKeysPanning] is `true` by default. + /// + /// Use [KeyboardOptions.disabled] to disable the keyboard keys. + const KeyboardOptions({ + this.enableArrowKeysPanning = true, + this.enableWASDPanning = false, + this.enableQERotating = false, + this.enableRFZooming = false, + this.panSpeedCalculator, + this.zoomSpeedCalculator, + this.rotateSpeedCalculator, + }); + + /// Disable keyboard control of the map + /// + /// [CursorKeyboardRotationOptions] may still be active, and is not disabled + /// if this is disabled. + const KeyboardOptions.disabled() : this(enableArrowKeysPanning: false); + + /// The default [KeyboardOptions.panSpeedCalculator] + static double defaultPanSpeedCalculator(int counter) => switch (counter) { + 1 => 2, + <= 20 => 5, + <= 25 => 10, + <= 30 => 20, + <= 50 => 30, + <= 100 => 40, + _ => 50, + }; + + /// The default [KeyboardOptions.rotateSpeedCalculator] + static double defaultRotateSpeedCalculator(int counter) => switch (counter) { + 1 => 1, + <= 20 => 5, + _ => 10, + }; + + /// The default [KeyboardOptions.zoomSpeedCalculator] + static double defaultZoomSpeedCalculator(int counter) => switch (counter) { + 1 => 0.01, + <= 10 => 0.1, + <= 50 => 0.2, + _ => 0.5, + }; +} From 83a29cdbb74b939d576cf1d8e1eb05cd4e12dd07 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 19 Nov 2024 12:32:27 +0000 Subject: [PATCH 02/19] Fixed linting issues --- example/lib/pages/home.dart | 2 +- lib/src/map/options/interaction.dart | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/example/lib/pages/home.dart b/example/lib/pages/home.dart index 615094c9c..c70c816fe 100644 --- a/example/lib/pages/home.dart +++ b/example/lib/pages/home.dart @@ -42,7 +42,7 @@ class _HomePageState extends State { const LatLng(90, 180), ), ), - interactionOptions: InteractionOptions( + interactionOptions: const InteractionOptions( keyboardOptions: KeyboardOptions( enableArrowKeysPanning: true, enableQERotating: true, diff --git a/lib/src/map/options/interaction.dart b/lib/src/map/options/interaction.dart index 7885a2725..6684f1360 100644 --- a/lib/src/map/options/interaction.dart +++ b/lib/src/map/options/interaction.dart @@ -1,5 +1,4 @@ import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/map/options/keyboard.dart'; import 'package:meta/meta.dart'; /// All interactive options for [FlutterMap] From e4a59b9640a4995c35ae05c2a3ca93f370c162d2 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 19 Nov 2024 17:53:57 +0000 Subject: [PATCH 03/19] Switched to using physical keys to support other keyboard models Switched to using `Focus` instead of `KeyboardListener` to properly take focus and make bubbling decisions Added optional external `foucsNode` & `autofocus` input to `KeyboardOptions` Documented recommendation to enable arrow keys if WASD enabled (for left handed users) --- lib/src/gestures/map_interactive_viewer.dart | 82 +++++++++++--------- lib/src/map/options/keyboard.dart | 39 +++++++++- 2 files changed, 82 insertions(+), 39 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index affeba7cb..e20ad1653 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -92,11 +92,11 @@ class MapInteractiveViewerState extends State int _tapUpCounter = 0; Timer? _doubleTapHoldMaxDelay; - late final _keyboardListenerFocusNode = FocusNode(); + late final FocusNode _keyboardListenerFocusNode; int _keyboardPanEventCounter = 0; int _keyboardRotateEventCounter = 0; int _keyboardZoomEventCounter = 0; - final _keyboardPanKeyDownSet = {}; + final _keyboardPanKeyDownSet = {}; MapCamera get _camera => widget.controller.camera; @@ -118,6 +118,10 @@ class MapInteractiveViewerState extends State ServicesBinding.instance.keyboard .addHandler(cursorKeyboardRotationTriggerHandler); + + _keyboardListenerFocusNode = + _interactionOptions.keyboardOptions.focusNode ?? + FocusNode(debugLabel: 'FlutterMap'); } @override @@ -295,7 +299,9 @@ class MapInteractiveViewerState extends State @override Widget build(BuildContext context) { - return KeyboardListener( + return Focus( + debugLabel: 'FlutterMap', + autofocus: _interactionOptions.keyboardOptions.autofocus, focusNode: _keyboardListenerFocusNode, onKeyEvent: _onKeyEvent, child: Listener( @@ -328,7 +334,7 @@ class MapInteractiveViewerState extends State ); } - void _onKeyEvent(KeyEvent evt) { + KeyEventResult _onKeyEvent(FocusNode _, KeyEvent evt) { late final arrowKeysGate = _options.interactionOptions.keyboardOptions.enableArrowKeysPanning ? (evt.logicalKey != LogicalKeyboardKey.arrowLeft && @@ -354,28 +360,30 @@ class MapInteractiveViewerState extends State evt.logicalKey != LogicalKeyboardKey.keyF) : true; - if (arrowKeysGate && wasdKeysGate && qeKeysGate && rfKeysGate) return; + if (arrowKeysGate && wasdKeysGate && qeKeysGate && rfKeysGate) { + return KeyEventResult.ignored; + } late final arrowKeys = _options.interactionOptions.keyboardOptions.enableArrowKeysPanning && - (evt.logicalKey == LogicalKeyboardKey.arrowLeft || - evt.logicalKey == LogicalKeyboardKey.arrowRight || - evt.logicalKey == LogicalKeyboardKey.arrowUp || - evt.logicalKey == LogicalKeyboardKey.arrowDown); + (evt.physicalKey == PhysicalKeyboardKey.arrowLeft || + evt.physicalKey == PhysicalKeyboardKey.arrowRight || + evt.physicalKey == PhysicalKeyboardKey.arrowUp || + evt.physicalKey == PhysicalKeyboardKey.arrowDown); late final wasdKeys = _options.interactionOptions.keyboardOptions.enableWASDPanning && - (evt.logicalKey == LogicalKeyboardKey.keyW || - evt.logicalKey == LogicalKeyboardKey.keyA || - evt.logicalKey == LogicalKeyboardKey.keyS || - evt.logicalKey == LogicalKeyboardKey.keyD); + (evt.physicalKey == PhysicalKeyboardKey.keyW || + evt.physicalKey == PhysicalKeyboardKey.keyA || + evt.physicalKey == PhysicalKeyboardKey.keyS || + evt.physicalKey == PhysicalKeyboardKey.keyD); late final qeKeys = _options.interactionOptions.keyboardOptions.enableQERotating && - (evt.logicalKey == LogicalKeyboardKey.keyQ || - evt.logicalKey == LogicalKeyboardKey.keyE); + (evt.physicalKey == PhysicalKeyboardKey.keyQ || + evt.physicalKey == PhysicalKeyboardKey.keyE); late final rfKeys = _options.interactionOptions.keyboardOptions.enableRFZooming && - (evt.logicalKey == LogicalKeyboardKey.keyR || - evt.logicalKey == LogicalKeyboardKey.keyF); + (evt.physicalKey == PhysicalKeyboardKey.keyR || + evt.physicalKey == PhysicalKeyboardKey.keyF); if (evt is KeyDownEvent) { if (arrowKeys || wasdKeys) { @@ -384,24 +392,24 @@ class MapInteractiveViewerState extends State _closeFlingAnimationController(MapEventSource.keyboard); _closeDoubleTapController(MapEventSource.keyboard); } - _keyboardPanKeyDownSet.add(evt.logicalKey); - } - if (qeKeys) { + _keyboardPanKeyDownSet.add(evt.physicalKey); + } else if (qeKeys) { _keyboardRotateEventCounter = 0; _closeFlingAnimationController(MapEventSource.keyboard); _closeDoubleTapController(MapEventSource.keyboard); - } - if (rfKeys) { + } else if (rfKeys) { _keyboardZoomEventCounter = 0; _closeFlingAnimationController(MapEventSource.keyboard); _closeDoubleTapController(MapEventSource.keyboard); + } else { + return KeyEventResult.skipRemainingHandlers; } } if (evt is KeyUpEvent) { if (arrowKeys || wasdKeys) { - _keyboardPanKeyDownSet.remove(evt.logicalKey); + _keyboardPanKeyDownSet.remove(evt.physicalKey); } - return; + return KeyEventResult.skipRemainingHandlers; } if (arrowKeys || wasdKeys) _keyboardPanEventCounter++; @@ -416,17 +424,17 @@ class MapInteractiveViewerState extends State for (final key in _keyboardPanKeyDownSet) { newCenter = newCenter + switch (key) { - LogicalKeyboardKey.arrowLeft || - LogicalKeyboardKey.keyA => + PhysicalKeyboardKey.arrowLeft || + PhysicalKeyboardKey.keyA => Point(-panSpeed, 0), - LogicalKeyboardKey.arrowRight || - LogicalKeyboardKey.keyD => + PhysicalKeyboardKey.arrowRight || + PhysicalKeyboardKey.keyD => Point(panSpeed, 0), - LogicalKeyboardKey.arrowUp || - LogicalKeyboardKey.keyW => + PhysicalKeyboardKey.arrowUp || + PhysicalKeyboardKey.keyW => Point(0, -panSpeed), - LogicalKeyboardKey.arrowDown || - LogicalKeyboardKey.keyS => + PhysicalKeyboardKey.arrowDown || + PhysicalKeyboardKey.keyS => Point(0, panSpeed), _ => throw StateError( '`_keyboardPanKeyDownSet` should only contain arrow & WASD keys', @@ -441,10 +449,10 @@ class MapInteractiveViewerState extends State _keyboardRotateEventCounter); var newRotation = _camera.rotation; if (qeKeys) { - if (evt.logicalKey == LogicalKeyboardKey.keyQ) { + if (evt.physicalKey == PhysicalKeyboardKey.keyQ) { newRotation -= rotateSpeed; } - if (evt.logicalKey == LogicalKeyboardKey.keyE) { + if (evt.physicalKey == PhysicalKeyboardKey.keyE) { newRotation += rotateSpeed; } } @@ -455,10 +463,10 @@ class MapInteractiveViewerState extends State KeyboardOptions.defaultZoomSpeedCalculator(_keyboardZoomEventCounter); var newZoom = _camera.zoom; if (rfKeys) { - if (evt.logicalKey == LogicalKeyboardKey.keyR) { + if (evt.physicalKey == PhysicalKeyboardKey.keyR) { newZoom += zoomSpeed; } - if (evt.logicalKey == LogicalKeyboardKey.keyF) { + if (evt.physicalKey == PhysicalKeyboardKey.keyF) { newZoom -= zoomSpeed; } } @@ -471,6 +479,8 @@ class MapInteractiveViewerState extends State hasGesture: true, source: MapEventSource.keyboard, ); + + return KeyEventResult.handled; } void _onPointerDown(PointerDownEvent event) { diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index ba13aad36..09c7b40e8 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -1,4 +1,5 @@ import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:meta/meta.dart'; @@ -20,16 +21,31 @@ class KeyboardOptions { /// This is enabled by default. final bool enableArrowKeysPanning; - /// Whether to allow the W, A, S, D keys to pan the map (in the directions + /// Whether to allow the W, A, S, D keys (*) to pan the map (in the directions /// UP, LEFT, DOWN, RIGHT respectively) + /// + /// WASD are only the physical and logical keys on QWERTY keyboards. On non- + /// QWERTY keyboards, such as AZERTY, the keys in the same position as on the + /// QWERTY keyboard is used (ie. ZQSD on AZERTY). + /// + /// If enabled, it is recommended to enable [enableArrowKeysPanning] to + /// provide panning functionality easily for left handed users. final bool enableWASDPanning; - /// Whether to allow the Q & E keys to rotate the map (Q rotates COUNTER- + /// Whether to allow the Q & E keys (*) to rotate the map (Q rotates COUNTER- /// CLOCKWISE, E rotates CLOCKWISE) + /// + /// QE are only the physical and logical keys on QWERTY keyboards. On non- + /// QWERTY keyboards, such as AZERTY, the keys in the same position as on the + /// QWERTY keyboard is used (ie. AE on AZERTY). final bool enableQERotating; /// Whether to allow the R & F keys to zoom the map (R zooms IN (increases /// zoom level), F zooms OUT (decreases zoom level)) + /// + /// RF are only the physical and logical keys on QWERTY keyboards. On non- + /// QWERTY keyboards, such as AZERTY, the keys in the same position as on the + /// QWERTY keyboard is used (ie. RF on AZERTY). final bool enableRFZooming; /// Calculates the transformation to apply to the camera's position, where @@ -56,6 +72,17 @@ class KeyboardOptions { /// Defaults to [defaultRotateSpeedCalculator]. final KeyboardEffectSpeedCalculator? rotateSpeedCalculator; + /// Custom [FocusNode] to be used instead of internal node + /// + /// May cause unexpected behaviour. + final FocusNode? focusNode; + + /// Whether to request focus as soon as the map widget appears (and to enable + /// keyboard controls) + /// + /// Defaults to `true`. + final bool autofocus; + /// Create options which specify how the map may be controlled by keyboard /// keys /// @@ -70,13 +97,19 @@ class KeyboardOptions { this.panSpeedCalculator, this.zoomSpeedCalculator, this.rotateSpeedCalculator, + this.focusNode, + this.autofocus = true, }); /// Disable keyboard control of the map /// /// [CursorKeyboardRotationOptions] may still be active, and is not disabled /// if this is disabled. - const KeyboardOptions.disabled() : this(enableArrowKeysPanning: false); + const KeyboardOptions.disabled() + : this( + enableArrowKeysPanning: false, + autofocus: false, + ); /// The default [KeyboardOptions.panSpeedCalculator] static double defaultPanSpeedCalculator(int counter) => switch (counter) { From 4c1f2010640bcdf7067481a4a2d151c1422b779e Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 19 Nov 2024 17:56:01 +0000 Subject: [PATCH 04/19] Fixed linting issue --- lib/src/map/options/keyboard.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index 09c7b40e8..3992b56d3 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -1,7 +1,6 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:meta/meta.dart'; /// A callback function which takes as input the number of times the concerned /// keyboard key has been pressed down & repeated ([KeyDownEvent] & From 1ecb94083e013beb18ec8f3d2519d1a5091767be Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 19 Nov 2024 18:02:19 +0000 Subject: [PATCH 05/19] Removed unnecessary guard clauses Fixed returned `KeyEventResult`s --- lib/src/gestures/map_interactive_viewer.dart | 85 ++++++-------------- 1 file changed, 26 insertions(+), 59 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index e20ad1653..00eae608c 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -335,55 +335,24 @@ class MapInteractiveViewerState extends State } KeyEventResult _onKeyEvent(FocusNode _, KeyEvent evt) { - late final arrowKeysGate = - _options.interactionOptions.keyboardOptions.enableArrowKeysPanning - ? (evt.logicalKey != LogicalKeyboardKey.arrowLeft && - evt.logicalKey != LogicalKeyboardKey.arrowRight && - evt.logicalKey != LogicalKeyboardKey.arrowUp && - evt.logicalKey != LogicalKeyboardKey.arrowDown) - : true; - late final wasdKeysGate = - _options.interactionOptions.keyboardOptions.enableWASDPanning - ? (evt.logicalKey != LogicalKeyboardKey.keyW && - evt.logicalKey != LogicalKeyboardKey.keyA && - evt.logicalKey != LogicalKeyboardKey.keyS && - evt.logicalKey != LogicalKeyboardKey.keyD) - : true; - late final qeKeysGate = - _options.interactionOptions.keyboardOptions.enableQERotating - ? (evt.logicalKey != LogicalKeyboardKey.keyQ && - evt.logicalKey != LogicalKeyboardKey.keyE) - : true; - late final rfKeysGate = - _options.interactionOptions.keyboardOptions.enableRFZooming - ? (evt.logicalKey != LogicalKeyboardKey.keyR && - evt.logicalKey != LogicalKeyboardKey.keyF) - : true; - - if (arrowKeysGate && wasdKeysGate && qeKeysGate && rfKeysGate) { - return KeyEventResult.ignored; - } - - late final arrowKeys = - _options.interactionOptions.keyboardOptions.enableArrowKeysPanning && - (evt.physicalKey == PhysicalKeyboardKey.arrowLeft || - evt.physicalKey == PhysicalKeyboardKey.arrowRight || - evt.physicalKey == PhysicalKeyboardKey.arrowUp || - evt.physicalKey == PhysicalKeyboardKey.arrowDown); - late final wasdKeys = - _options.interactionOptions.keyboardOptions.enableWASDPanning && - (evt.physicalKey == PhysicalKeyboardKey.keyW || - evt.physicalKey == PhysicalKeyboardKey.keyA || - evt.physicalKey == PhysicalKeyboardKey.keyS || - evt.physicalKey == PhysicalKeyboardKey.keyD); - late final qeKeys = - _options.interactionOptions.keyboardOptions.enableQERotating && - (evt.physicalKey == PhysicalKeyboardKey.keyQ || - evt.physicalKey == PhysicalKeyboardKey.keyE); - late final rfKeys = - _options.interactionOptions.keyboardOptions.enableRFZooming && - (evt.physicalKey == PhysicalKeyboardKey.keyR || - evt.physicalKey == PhysicalKeyboardKey.keyF); + final keyboardOptions = _interactionOptions.keyboardOptions; + + late final arrowKeys = keyboardOptions.enableArrowKeysPanning && + (evt.physicalKey == PhysicalKeyboardKey.arrowLeft || + evt.physicalKey == PhysicalKeyboardKey.arrowRight || + evt.physicalKey == PhysicalKeyboardKey.arrowUp || + evt.physicalKey == PhysicalKeyboardKey.arrowDown); + late final wasdKeys = keyboardOptions.enableWASDPanning && + (evt.physicalKey == PhysicalKeyboardKey.keyW || + evt.physicalKey == PhysicalKeyboardKey.keyA || + evt.physicalKey == PhysicalKeyboardKey.keyS || + evt.physicalKey == PhysicalKeyboardKey.keyD); + late final qeKeys = keyboardOptions.enableQERotating && + (evt.physicalKey == PhysicalKeyboardKey.keyQ || + evt.physicalKey == PhysicalKeyboardKey.keyE); + late final rfKeys = keyboardOptions.enableRFZooming && + (evt.physicalKey == PhysicalKeyboardKey.keyR || + evt.physicalKey == PhysicalKeyboardKey.keyF); if (evt is KeyDownEvent) { if (arrowKeys || wasdKeys) { @@ -402,24 +371,24 @@ class MapInteractiveViewerState extends State _closeFlingAnimationController(MapEventSource.keyboard); _closeDoubleTapController(MapEventSource.keyboard); } else { - return KeyEventResult.skipRemainingHandlers; + return KeyEventResult.ignored; } } if (evt is KeyUpEvent) { if (arrowKeys || wasdKeys) { _keyboardPanKeyDownSet.remove(evt.physicalKey); + return KeyEventResult.handled; } - return KeyEventResult.skipRemainingHandlers; + return KeyEventResult.ignored; } if (arrowKeys || wasdKeys) _keyboardPanEventCounter++; if (qeKeys) _keyboardRotateEventCounter++; if (rfKeys) _keyboardZoomEventCounter++; - final panSpeed = _options - .interactionOptions.keyboardOptions.panSpeedCalculator - ?.call(_keyboardPanEventCounter) ?? - KeyboardOptions.defaultPanSpeedCalculator(_keyboardPanEventCounter); + final panSpeed = + keyboardOptions.panSpeedCalculator?.call(_keyboardPanEventCounter) ?? + KeyboardOptions.defaultPanSpeedCalculator(_keyboardPanEventCounter); var newCenter = _camera.latLngToScreenPoint(_camera.center); for (final key in _keyboardPanKeyDownSet) { newCenter = newCenter + @@ -442,8 +411,7 @@ class MapInteractiveViewerState extends State }; } - final rotateSpeed = _options - .interactionOptions.keyboardOptions.rotateSpeedCalculator + final rotateSpeed = keyboardOptions.rotateSpeedCalculator ?.call(_keyboardRotateEventCounter) ?? KeyboardOptions.defaultRotateSpeedCalculator( _keyboardRotateEventCounter); @@ -457,8 +425,7 @@ class MapInteractiveViewerState extends State } } - final zoomSpeed = _options - .interactionOptions.keyboardOptions.zoomSpeedCalculator + final zoomSpeed = keyboardOptions.zoomSpeedCalculator ?.call(_keyboardZoomEventCounter) ?? KeyboardOptions.defaultZoomSpeedCalculator(_keyboardZoomEventCounter); var newZoom = _camera.zoom; From 805f882c337ce944f521d8567bb743d7d4ae5449 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 19 Nov 2024 18:07:16 +0000 Subject: [PATCH 06/19] Removed duplicate 'dart:math' import --- lib/src/gestures/map_interactive_viewer.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 00eae608c..6ba0f4e0c 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:math' as math; -import 'dart:math'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; @@ -395,16 +394,16 @@ class MapInteractiveViewerState extends State switch (key) { PhysicalKeyboardKey.arrowLeft || PhysicalKeyboardKey.keyA => - Point(-panSpeed, 0), + math.Point(-panSpeed, 0), PhysicalKeyboardKey.arrowRight || PhysicalKeyboardKey.keyD => - Point(panSpeed, 0), + math.Point(panSpeed, 0), PhysicalKeyboardKey.arrowUp || PhysicalKeyboardKey.keyW => - Point(0, -panSpeed), + math.Point(0, -panSpeed), PhysicalKeyboardKey.arrowDown || PhysicalKeyboardKey.keyS => - Point(0, panSpeed), + math.Point(0, panSpeed), _ => throw StateError( '`_keyboardPanKeyDownSet` should only contain arrow & WASD keys', ), From 3c9e0d5ce154642f0a96e47a678abbc227725211 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 26 Nov 2024 10:04:20 +0000 Subject: [PATCH 07/19] Improved interactive flags example page --- example/lib/pages/home.dart | 8 - example/lib/pages/interactive_test_page.dart | 308 ++++++++++++++----- 2 files changed, 225 insertions(+), 91 deletions(-) diff --git a/example/lib/pages/home.dart b/example/lib/pages/home.dart index c70c816fe..a63062ff6 100644 --- a/example/lib/pages/home.dart +++ b/example/lib/pages/home.dart @@ -42,14 +42,6 @@ class _HomePageState extends State { const LatLng(90, 180), ), ), - interactionOptions: const InteractionOptions( - keyboardOptions: KeyboardOptions( - enableArrowKeysPanning: true, - enableQERotating: true, - enableRFZooming: true, - enableWASDPanning: true, - ), - ), ), children: [ openStreetMapTileLayer, diff --git a/example/lib/pages/interactive_test_page.dart b/example/lib/pages/interactive_test_page.dart index 8c0cb518b..75a378138 100644 --- a/example/lib/pages/interactive_test_page.dart +++ b/example/lib/pages/interactive_test_page.dart @@ -14,28 +14,16 @@ class InteractiveFlagsPage extends StatefulWidget { } class _InteractiveFlagsPageState extends State { - static const availableFlags = { - 'Movement': { - InteractiveFlag.drag: 'Drag', - InteractiveFlag.flingAnimation: 'Fling', - InteractiveFlag.pinchMove: 'Pinch', - }, - 'Zooming': { - InteractiveFlag.pinchZoom: 'Pinch', - InteractiveFlag.scrollWheelZoom: 'Scroll', - InteractiveFlag.doubleTapZoom: 'Double tap', - InteractiveFlag.doubleTapDragZoom: '+ drag', - }, - 'Rotation': { - InteractiveFlag.rotate: 'Twist', - }, - }; + final flagsSet = + ValueNotifier(InteractiveFlag.drag | InteractiveFlag.pinchZoom); - int flags = InteractiveFlag.drag | InteractiveFlag.pinchZoom; bool keyboardCursorRotate = false; + bool keyboardArrowsMove = false; + bool keyboardWASDMove = false; + bool keyboardQERotate = false; + bool keyboardRFZoom = false; MapEvent? _latestEvent; - @override Widget build(BuildContext context) { final screenWidth = MediaQuery.sizeOf(context).width; @@ -50,58 +38,181 @@ class _InteractiveFlagsPageState extends State { direction: screenWidth >= 600 ? Axis.horizontal : Axis.vertical, mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: availableFlags.entries - .map( - (category) => Column( + children: [ + Column( + children: [ + const Text( + 'Move/Pan', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - category.key, - style: const TextStyle(fontWeight: FontWeight.bold), + InteractiveFlagCheckbox( + name: 'Drag', + flag: InteractiveFlag.drag, + flagsSet: flagsSet, + ), + const SizedBox(width: 8), + InteractiveFlagCheckbox( + name: 'Fling', + flag: InteractiveFlag.flingAnimation, + flagsSet: flagsSet, ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...category.value.entries.map( - (e) => Column( - children: [ - Checkbox.adaptive( - value: - InteractiveFlag.hasFlag(e.key, flags), - onChanged: (enabled) { - if (!enabled!) { - setState(() => flags &= ~e.key); - return; - } - setState(() => flags |= e.key); - }, - ), - Text(e.value), - ], + const SizedBox(width: 8), + InteractiveFlagCheckbox( + name: 'Pinch', + flag: InteractiveFlag.pinchMove, + flagsSet: flagsSet, + ), + const SizedBox(width: 8), + Column( + children: [ + Checkbox.adaptive( + value: keyboardArrowsMove, + onChanged: (enabled) => setState( + () => keyboardArrowsMove = enabled!, ), ), - if (category.key == 'Rotation') ...[ - Column( - children: [ - Checkbox.adaptive( - value: keyboardCursorRotate, - onChanged: (enabled) => setState( - () => keyboardCursorRotate = enabled!), - ), - const Text('Cursor & CTRL'), - ], + const Text( + 'Keyboard\nArrows', + textAlign: TextAlign.center, + ), + ], + ), + const SizedBox(width: 8), + Column( + children: [ + Checkbox.adaptive( + value: keyboardWASDMove, + onChanged: (enabled) => setState( + () => keyboardWASDMove = enabled!, ), - ] - ].interleave(const SizedBox(width: 12)).toList() - ..removeLast(), - ) + ), + const Text( + 'Keyboard\nW/A/S/D', + textAlign: TextAlign.center, + ), + ], + ), ], + ) + ], + ), + const SizedBox(width: 12), + Column( + children: [ + const Text( + 'Zoom', + style: TextStyle( + fontWeight: FontWeight.bold, + ), ), - ) - .interleave( - screenWidth >= 600 ? null : const SizedBox(height: 12), - ) - .whereType() - .toList(), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InteractiveFlagCheckbox( + name: 'Pinch', + flag: InteractiveFlag.pinchZoom, + flagsSet: flagsSet, + ), + const SizedBox(width: 8), + InteractiveFlagCheckbox( + name: 'Scroll', + flag: InteractiveFlag.scrollWheelZoom, + flagsSet: flagsSet, + ), + const SizedBox(width: 8), + InteractiveFlagCheckbox( + name: 'Double tap', + flag: InteractiveFlag.doubleTapZoom, + flagsSet: flagsSet, + ), + const SizedBox(width: 8), + InteractiveFlagCheckbox( + name: '+ drag', + flag: InteractiveFlag.doubleTapDragZoom, + flagsSet: flagsSet, + ), + const SizedBox(width: 8), + Column( + children: [ + Checkbox.adaptive( + value: keyboardRFZoom, + onChanged: (enabled) => setState( + () => keyboardRFZoom = enabled!, + ), + ), + const Text( + 'Keyboard\nR/F', + textAlign: TextAlign.center, + ), + ], + ), + ], + ) + ], + ), + const SizedBox(width: 12), + Column( + children: [ + const Text( + 'Rotate', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InteractiveFlagCheckbox( + name: 'Twist', + flag: InteractiveFlag.rotate, + flagsSet: flagsSet, + ), + const SizedBox(width: 8), + Column( + children: [ + Checkbox.adaptive( + value: keyboardCursorRotate, + onChanged: (enabled) => setState( + () => keyboardCursorRotate = enabled!, + ), + ), + const Text( + 'Cursor\n& CTRL', + textAlign: TextAlign.center, + ), + ], + ), + const SizedBox(width: 8), + Column( + children: [ + Checkbox.adaptive( + value: keyboardQERotate, + onChanged: (enabled) => setState( + () => keyboardQERotate = enabled!, + ), + ), + const Text( + 'Keyboard\nQ/E', + textAlign: TextAlign.center, + ), + ], + ), + ], + ) + ], + ), + ], ), const Divider(), Padding( @@ -115,23 +226,33 @@ class _InteractiveFlagsPageState extends State { ), ), Expanded( - child: FlutterMap( - options: MapOptions( - onMapEvent: (evt) => setState(() => _latestEvent = evt), - initialCenter: const LatLng(51.5, -0.09), - initialZoom: 11, - interactionOptions: InteractionOptions( - flags: flags, - cursorKeyboardRotationOptions: - CursorKeyboardRotationOptions( - isKeyTrigger: (key) => - keyboardCursorRotate && - CursorKeyboardRotationOptions.defaultTriggerKeys - .contains(key), + child: ValueListenableBuilder( + valueListenable: flagsSet, + builder: (context, value, child) => FlutterMap( + options: MapOptions( + onMapEvent: (evt) => setState(() => _latestEvent = evt), + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 11, + interactionOptions: InteractionOptions( + flags: value, + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions( + isKeyTrigger: (key) => + keyboardCursorRotate && + CursorKeyboardRotationOptions.defaultTriggerKeys + .contains(key), + ), + keyboardOptions: KeyboardOptions( + enableArrowKeysPanning: keyboardArrowsMove, + enableWASDPanning: keyboardWASDMove, + enableQERotating: keyboardQERotate, + enableRFZooming: keyboardRFZoom, + ), ), ), + children: [child!], ), - children: [openStreetMapTileLayer], + child: openStreetMapTileLayer, ), ), ], @@ -186,11 +307,32 @@ class _InteractiveFlagsPageState extends State { } } -extension _IterableExt on Iterable { - Iterable interleave(E separator) sync* { - for (int i = 0; i < length; i++) { - yield elementAt(i); - if (i < length) yield separator; - } +class InteractiveFlagCheckbox extends StatelessWidget { + const InteractiveFlagCheckbox({ + super.key, + required this.name, + required this.flag, + required this.flagsSet, + }); + + final String name; + final int flag; + final ValueNotifier flagsSet; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ValueListenableBuilder( + valueListenable: flagsSet, + builder: (context, value, _) => Checkbox.adaptive( + value: InteractiveFlag.hasFlag(flag, value), + onChanged: (enabled) => + flagsSet.value = !enabled! ? value &= ~flag : value |= flag, + ), + ), + Text(name), + ], + ); } } From 5e52ef758b6195de0a667b6fe970314270bb4ed3 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Thu, 5 Dec 2024 22:38:42 +0000 Subject: [PATCH 08/19] Update to use `Offset`s --- lib/src/gestures/map_interactive_viewer.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 68e428cc9..22e8f1a07 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -389,22 +389,22 @@ class MapInteractiveViewerState extends State final panSpeed = keyboardOptions.panSpeedCalculator?.call(_keyboardPanEventCounter) ?? KeyboardOptions.defaultPanSpeedCalculator(_keyboardPanEventCounter); - var newCenter = _camera.latLngToScreenPoint(_camera.center); + var newCenter = _camera.latLngToScreenOffset(_camera.center); for (final key in _keyboardPanKeyDownSet) { newCenter = newCenter + switch (key) { PhysicalKeyboardKey.arrowLeft || PhysicalKeyboardKey.keyA => - math.Point(-panSpeed, 0), + Offset(-panSpeed, 0), PhysicalKeyboardKey.arrowRight || PhysicalKeyboardKey.keyD => - math.Point(panSpeed, 0), + Offset(panSpeed, 0), PhysicalKeyboardKey.arrowUp || PhysicalKeyboardKey.keyW => - math.Point(0, -panSpeed), + Offset(0, -panSpeed), PhysicalKeyboardKey.arrowDown || PhysicalKeyboardKey.keyS => - math.Point(0, panSpeed), + Offset(0, panSpeed), _ => throw StateError( '`_keyboardPanKeyDownSet` should only contain arrow & WASD keys', ), @@ -439,7 +439,7 @@ class MapInteractiveViewerState extends State } widget.controller.moveAndRotateRaw( - _camera.pointToLatLng(newCenter), + _camera.screenOffsetToLatLng(newCenter), newZoom, newRotation % 360, offset: Offset.zero, From eb560ca8894ab4164a4b9bfb2c08b996fc23c172 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 12 Jan 2025 20:53:54 +0000 Subject: [PATCH 09/19] Buttery smooth: use animations instead of tying controller to key hold events --- lib/src/gestures/compound_animations.dart | 62 +++ lib/src/gestures/map_interactive_viewer.dart | 421 ++++++++++++++----- lib/src/map/options/keyboard.dart | 123 +++--- 3 files changed, 452 insertions(+), 154 deletions(-) create mode 100644 lib/src/gestures/compound_animations.dart diff --git a/lib/src/gestures/compound_animations.dart b/lib/src/gestures/compound_animations.dart new file mode 100644 index 000000000..6f906b66b --- /dev/null +++ b/lib/src/gestures/compound_animations.dart @@ -0,0 +1,62 @@ +part of 'map_interactive_viewer.dart'; + +mixin _InfiniteNotifier on CompoundAnimation { + @override + void didStartListening() { + first.addListener(notifyListeners); + first.addStatusListener(_maybeNotifyStatusListeners); + next.addListener(notifyListeners); + next.addStatusListener(_maybeNotifyStatusListeners); + } + + @override + void didStopListening() { + first.removeListener(notifyListeners); + first.removeStatusListener(_maybeNotifyStatusListeners); + next.removeListener(notifyListeners); + next.removeStatusListener(_maybeNotifyStatusListeners); + } + + AnimationStatus? _lastStatus; + void _maybeNotifyStatusListeners(AnimationStatus _) { + if (status != _lastStatus) { + _lastStatus = status; + notifyStatusListeners(status); + } + } +} + +class _NumInfiniteSumAnimation extends CompoundAnimation + with _InfiniteNotifier { + _NumInfiniteSumAnimation(Animation a, Animation b) + : super(first: a, next: b); + + @override + T get value => first.value + next.value as T; +} + +class _OffsetInfiniteSumAnimation extends CompoundAnimation + with _InfiniteNotifier { + _OffsetInfiniteSumAnimation(Animation a, Animation b) + : super(first: a, next: b); + + @override + Offset get value => first.value + next.value; +} + +@internal +class InfiniteAnimation extends CompoundAnimation with _InfiniteNotifier { + InfiniteAnimation(Animation repeat, Animation curve) + : super(first: repeat, next: curve); + + @override + AnimationStatus get status => switch (next.status) { + AnimationStatus.completed => AnimationStatus.forward, + AnimationStatus.forward => AnimationStatus.forward, + AnimationStatus.dismissed => AnimationStatus.dismissed, + AnimationStatus.reverse => AnimationStatus.reverse, + }; + + @override + T get value => !next.isCompleted ? next.value : first.value; +} diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 22e8f1a07..e53f3f58f 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -7,8 +7,11 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:latlong2/latlong.dart'; +import 'package:meta/meta.dart'; import 'package:vector_math/vector_math_64.dart'; +part 'package:flutter_map/src/gestures/compound_animations.dart'; + /// The method signature of the builder. typedef InteractiveViewerBuilder = Widget Function( BuildContext context, @@ -92,11 +95,97 @@ class MapInteractiveViewerState extends State int _tapUpCounter = 0; Timer? _doubleTapHoldMaxDelay; + //! Keyboard animation late final FocusNode _keyboardListenerFocusNode; - int _keyboardPanEventCounter = 0; - int _keyboardRotateEventCounter = 0; - int _keyboardZoomEventCounter = 0; - final _keyboardPanKeyDownSet = {}; + late final List _keyboardListenersDisposal; + var _panLeapCancelCompleter = Completer(); + var _zoomLeapCancelCompleter = Completer(); + var _rotateLeapCancelCompleter = Completer(); + + List< + ({ + AnimationController curve, + Animation curveAnimation, + Tween curveTween, + AnimationController repeat, + Animation repeatAnimation, + Tween repeatTween, + })> _generateKeyboardAnimationManager({ + required List maxVelocities, + required T zero, + }) => + maxVelocities.map((end) { + final repeat = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 10), // always repeated + ); + final curve = AnimationController( + vsync: this, + duration: _options + .interactionOptions.keyboardOptions.animationCurveDuration, + reverseDuration: _options + .interactionOptions.keyboardOptions.animationCurveReverseDuration, + ); + final repeatTween = Tween(begin: end, end: end); + final curveTween = Tween(begin: zero, end: end); + + return ( + repeat: repeat, + curve: curve, + repeatTween: repeatTween, + curveTween: curveTween, + repeatAnimation: repeatTween.animate(repeat), + curveAnimation: curveTween + .chain( + CurveTween( + curve: _options + .interactionOptions.keyboardOptions.animationCurveCurve, + ), + ) + .animate(curve), + ); + }).toList(growable: false); + + // Keyboard animation > pan + late var _keyboardAnimationPrevZoomLevel = _camera.zoom; + double _keyboardPanAnimationMaxVelocityCalculator(double zoom) => + _options.interactionOptions.keyboardOptions.maxPanVelocity?.call(zoom) ?? + 10 * math.log(0.1 * zoom + 1) + 1; + late final _initialKeyboardPanAnimationMaxVelocity = + _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); + late final _keyboardPanAnimationManager = _generateKeyboardAnimationManager( + maxVelocities: [ + Offset(0, -_initialKeyboardPanAnimationMaxVelocity), + Offset(0, _initialKeyboardPanAnimationMaxVelocity), + Offset(-_initialKeyboardPanAnimationMaxVelocity, 0), + Offset(_initialKeyboardPanAnimationMaxVelocity, 0), + ], + zero: Offset.zero, + ); + + // Keyboard animation > zoom + late final _keyboardZoomAnimationMaxVelocity = + _options.interactionOptions.keyboardOptions.maxZoomVelocity; + late final _keyboardZoomAnimationManager = + _generateKeyboardAnimationManager( + maxVelocities: [ + -_keyboardZoomAnimationMaxVelocity, + _keyboardZoomAnimationMaxVelocity, + ], + zero: 0, + ); + + // Keyboard animation > rotate + late final double _keyboardRotateAnimationMaxVelocity = + _options.interactionOptions.keyboardOptions.maxRotateVelocity; + late final _keyboardRotateAnimationManager = + _generateKeyboardAnimationManager( + maxVelocities: [ + -_keyboardRotateAnimationMaxVelocity, + _keyboardRotateAnimationMaxVelocity, + ], + zero: 0, + ); MapCamera get _camera => widget.controller.camera; @@ -116,6 +205,9 @@ class MapInteractiveViewerState extends State ..addListener(_handleDoubleTapZoomAnimation) ..addStatusListener(_doubleTapZoomStatusListener); + _keyboardListenersDisposal = + _keyboardAnimationsHandler().toList(growable: false); + ServicesBinding.instance.keyboard .addHandler(cursorKeyboardRotationTriggerHandler); @@ -145,12 +237,49 @@ class MapInteractiveViewerState extends State .removeHandler(cursorKeyboardRotationTriggerHandler); _keyboardListenerFocusNode.dispose(); + for (final e in _keyboardListenersDisposal) { + e(); + } + for (final e in _keyboardPanAnimationManager) { + e.curve.dispose(); + e.repeat.dispose(); + } + for (final e in _keyboardZoomAnimationManager) { + e.curve.dispose(); + e.repeat.dispose(); + } + for (final e in _keyboardRotateAnimationManager) { + e.curve.dispose(); + e.repeat.dispose(); + } super.dispose(); } /// Rebuilds the map widget - void onMapStateChange() => setState(() {}); + void onMapStateChange() { + if (_keyboardAnimationPrevZoomLevel != _camera.zoom) { + _keyboardAnimationPrevZoomLevel = _camera.zoom; + + for (final (i, e) in _keyboardPanAnimationManager.indexed) { + final newMaxVelocity = + _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); + final end = switch (i) { + 0 => Offset(0, -newMaxVelocity), + 1 => Offset(0, newMaxVelocity), + 2 => Offset(-newMaxVelocity, 0), + 3 => Offset(newMaxVelocity, 0), + _ => throw StateError('Unpossible'), + }; + + e.repeatTween.begin = end; + e.repeatTween.end = end; + e.curveTween.end = end; + // curveTween.begin should always remain 0 + } + } + setState(() {}); + } /// Handles key down events to detect if one of the trigger keys got pressed. bool cursorKeyboardRotationTriggerHandler(KeyEvent event) { @@ -337,117 +466,211 @@ class MapInteractiveViewerState extends State KeyEventResult _onKeyEvent(FocusNode _, KeyEvent evt) { final keyboardOptions = _interactionOptions.keyboardOptions; - late final arrowKeys = keyboardOptions.enableArrowKeysPanning && - (evt.physicalKey == PhysicalKeyboardKey.arrowLeft || - evt.physicalKey == PhysicalKeyboardKey.arrowRight || - evt.physicalKey == PhysicalKeyboardKey.arrowUp || - evt.physicalKey == PhysicalKeyboardKey.arrowDown); - late final wasdKeys = keyboardOptions.enableWASDPanning && - (evt.physicalKey == PhysicalKeyboardKey.keyW || - evt.physicalKey == PhysicalKeyboardKey.keyA || - evt.physicalKey == PhysicalKeyboardKey.keyS || - evt.physicalKey == PhysicalKeyboardKey.keyD); - late final qeKeys = keyboardOptions.enableQERotating && - (evt.physicalKey == PhysicalKeyboardKey.keyQ || - evt.physicalKey == PhysicalKeyboardKey.keyE); - late final rfKeys = keyboardOptions.enableRFZooming && - (evt.physicalKey == PhysicalKeyboardKey.keyR || - evt.physicalKey == PhysicalKeyboardKey.keyF); - - if (evt is KeyDownEvent) { - if (arrowKeys || wasdKeys) { - if (_keyboardPanKeyDownSet.isEmpty) { - _keyboardPanEventCounter = 0; - _closeFlingAnimationController(MapEventSource.keyboard); - _closeDoubleTapController(MapEventSource.keyboard); + void maybeLeap( + AnimationController curve, { + required Future cancelLeap, + }) { + if (keyboardOptions.performLeapTriggerDuration != null && + curve.lastElapsedDuration != null && + curve.lastElapsedDuration! < + keyboardOptions.performLeapTriggerDuration!) { + void leaper(AnimationStatus status) { + if (status == AnimationStatus.completed) { + curve.reverse(); + curve.removeStatusListener(leaper); + } } - _keyboardPanKeyDownSet.add(evt.physicalKey); - } else if (qeKeys) { - _keyboardRotateEventCounter = 0; - _closeFlingAnimationController(MapEventSource.keyboard); - _closeDoubleTapController(MapEventSource.keyboard); - } else if (rfKeys) { - _keyboardZoomEventCounter = 0; - _closeFlingAnimationController(MapEventSource.keyboard); - _closeDoubleTapController(MapEventSource.keyboard); + + curve.addStatusListener(leaper); + cancelLeap.then((_) => curve.removeStatusListener(leaper)); } else { - return KeyEventResult.ignored; + curve.reverse(); } } - if (evt is KeyUpEvent) { - if (arrowKeys || wasdKeys) { - _keyboardPanKeyDownSet.remove(evt.physicalKey); + + if (keyboardOptions.enableArrowKeysPanning || + keyboardOptions.enableWASDPanning) { + final upKey = (keyboardOptions.enableArrowKeysPanning && + evt.physicalKey == PhysicalKeyboardKey.arrowUp) || + (keyboardOptions.enableWASDPanning && + evt.physicalKey == PhysicalKeyboardKey.keyW); + final downKey = (keyboardOptions.enableArrowKeysPanning && + evt.physicalKey == PhysicalKeyboardKey.arrowDown) || + (keyboardOptions.enableWASDPanning && + evt.physicalKey == PhysicalKeyboardKey.keyS); + final leftKey = (keyboardOptions.enableArrowKeysPanning && + evt.physicalKey == PhysicalKeyboardKey.arrowLeft) || + (keyboardOptions.enableWASDPanning && + evt.physicalKey == PhysicalKeyboardKey.keyA); + final rightKey = (keyboardOptions.enableArrowKeysPanning && + evt.physicalKey == PhysicalKeyboardKey.arrowRight) || + (keyboardOptions.enableWASDPanning && + evt.physicalKey == PhysicalKeyboardKey.keyD); + + if (upKey || downKey || leftKey || rightKey) { + final curve = _keyboardPanAnimationManager[upKey + ? 0 + : downKey + ? 1 + : leftKey + ? 2 + : 3] + .curve; + if (evt is KeyDownEvent) { + if (curve.isAnimating) { + _panLeapCancelCompleter.complete(); + _panLeapCancelCompleter = Completer(); + } + curve.forward(); + } + if (evt is KeyUpEvent) { + maybeLeap(curve, cancelLeap: _panLeapCancelCompleter.future); + } return KeyEventResult.handled; } - return KeyEventResult.ignored; } - if (arrowKeys || wasdKeys) _keyboardPanEventCounter++; - if (qeKeys) _keyboardRotateEventCounter++; - if (rfKeys) _keyboardZoomEventCounter++; - - final panSpeed = - keyboardOptions.panSpeedCalculator?.call(_keyboardPanEventCounter) ?? - KeyboardOptions.defaultPanSpeedCalculator(_keyboardPanEventCounter); - var newCenter = _camera.latLngToScreenOffset(_camera.center); - for (final key in _keyboardPanKeyDownSet) { - newCenter = newCenter + - switch (key) { - PhysicalKeyboardKey.arrowLeft || - PhysicalKeyboardKey.keyA => - Offset(-panSpeed, 0), - PhysicalKeyboardKey.arrowRight || - PhysicalKeyboardKey.keyD => - Offset(panSpeed, 0), - PhysicalKeyboardKey.arrowUp || - PhysicalKeyboardKey.keyW => - Offset(0, -panSpeed), - PhysicalKeyboardKey.arrowDown || - PhysicalKeyboardKey.keyS => - Offset(0, panSpeed), - _ => throw StateError( - '`_keyboardPanKeyDownSet` should only contain arrow & WASD keys', - ), - }; + if (keyboardOptions.enableRFZooming) { + final outKey = evt.physicalKey == PhysicalKeyboardKey.keyF; + final inKey = evt.physicalKey == PhysicalKeyboardKey.keyR; + + if (outKey || inKey) { + final curve = _keyboardZoomAnimationManager[outKey ? 0 : 1].curve; + if (evt is KeyDownEvent) { + if (curve.isAnimating) { + _zoomLeapCancelCompleter.complete(); + _zoomLeapCancelCompleter = Completer(); + } + curve.forward(); + } + if (evt is KeyUpEvent) { + maybeLeap(curve, cancelLeap: _zoomLeapCancelCompleter.future); + } + return KeyEventResult.handled; + } } - final rotateSpeed = keyboardOptions.rotateSpeedCalculator - ?.call(_keyboardRotateEventCounter) ?? - KeyboardOptions.defaultRotateSpeedCalculator( - _keyboardRotateEventCounter); - var newRotation = _camera.rotation; - if (qeKeys) { - if (evt.physicalKey == PhysicalKeyboardKey.keyQ) { - newRotation -= rotateSpeed; - } - if (evt.physicalKey == PhysicalKeyboardKey.keyE) { - newRotation += rotateSpeed; + if (keyboardOptions.enableQERotating) { + final anticlockwiseKey = evt.physicalKey == PhysicalKeyboardKey.keyQ; + final clockwiseKey = evt.physicalKey == PhysicalKeyboardKey.keyE; + + if (anticlockwiseKey || clockwiseKey) { + final curve = + _keyboardRotateAnimationManager[anticlockwiseKey ? 0 : 1].curve; + if (evt is KeyDownEvent) { + if (curve.isAnimating) { + _rotateLeapCancelCompleter.complete(); + _rotateLeapCancelCompleter = Completer(); + } + curve.forward(); + } + if (evt is KeyUpEvent) { + maybeLeap(curve, cancelLeap: _rotateLeapCancelCompleter.future); + } + return KeyEventResult.handled; } } - final zoomSpeed = keyboardOptions.zoomSpeedCalculator - ?.call(_keyboardZoomEventCounter) ?? - KeyboardOptions.defaultZoomSpeedCalculator(_keyboardZoomEventCounter); - var newZoom = _camera.zoom; - if (rfKeys) { - if (evt.physicalKey == PhysicalKeyboardKey.keyR) { - newZoom += zoomSpeed; - } - if (evt.physicalKey == PhysicalKeyboardKey.keyF) { - newZoom -= zoomSpeed; - } + return KeyEventResult.ignored; + } + + Iterable _keyboardAnimationsHandler() sync* { + final panAnimation = _OffsetInfiniteSumAnimation( + InfiniteAnimation( + _keyboardPanAnimationManager[0].repeatAnimation, + _keyboardPanAnimationManager[0].curveAnimation, + ), + _OffsetInfiniteSumAnimation( + InfiniteAnimation( + _keyboardPanAnimationManager[1].repeatAnimation, + _keyboardPanAnimationManager[1].curveAnimation, + ), + _OffsetInfiniteSumAnimation( + InfiniteAnimation( + _keyboardPanAnimationManager[2].repeatAnimation, + _keyboardPanAnimationManager[2].curveAnimation, + ), + InfiniteAnimation( + _keyboardPanAnimationManager[3].repeatAnimation, + _keyboardPanAnimationManager[3].curveAnimation, + ), + ), + ), + ); + void panAnimationListener() { + widget.controller.moveRaw( + _camera.screenOffsetToLatLng( + _camera.latLngToScreenOffset(_camera.center) + panAnimation.value, + ), + _camera.zoom, + hasGesture: true, + source: MapEventSource.keyboard, + ); } - widget.controller.moveAndRotateRaw( - _camera.screenOffsetToLatLng(newCenter), - newZoom, - newRotation % 360, - offset: Offset.zero, - hasGesture: true, - source: MapEventSource.keyboard, + panAnimation.addListener(panAnimationListener); + yield () => panAnimation.removeListener(panAnimationListener); + for (final e in _keyboardPanAnimationManager) { + e.curve.addStatusListener((status) { + if (status.isAnimating) e.repeat.stop(); + if (status.isCompleted) e.repeat.repeat(); + }); + } + + final zoomAnimation = _NumInfiniteSumAnimation( + InfiniteAnimation( + _keyboardZoomAnimationManager[0].repeatAnimation, + _keyboardZoomAnimationManager[0].curveAnimation, + ), + InfiniteAnimation( + _keyboardZoomAnimationManager[1].repeatAnimation, + _keyboardZoomAnimationManager[1].curveAnimation, + ), ); + void zoomAnimationListener() { + widget.controller.moveRaw( + _camera.center, + _camera.zoom + zoomAnimation.value, + hasGesture: true, + source: MapEventSource.keyboard, + ); + } + + zoomAnimation.addListener(zoomAnimationListener); + yield () => zoomAnimation.removeListener(zoomAnimationListener); + for (final e in _keyboardZoomAnimationManager) { + e.curve.addStatusListener((status) { + if (status.isAnimating) e.repeat.stop(); + if (status.isCompleted) e.repeat.repeat(); + }); + } + + final rotateAnimation = _NumInfiniteSumAnimation( + InfiniteAnimation( + _keyboardRotateAnimationManager[0].repeatAnimation, + _keyboardRotateAnimationManager[0].curveAnimation, + ), + InfiniteAnimation( + _keyboardRotateAnimationManager[1].repeatAnimation, + _keyboardRotateAnimationManager[1].curveAnimation, + ), + ); + void rotateAnimationListener() { + widget.controller.rotateRaw( + _camera.rotation + rotateAnimation.value, + hasGesture: true, + source: MapEventSource.keyboard, + ); + } - return KeyEventResult.handled; + rotateAnimation.addListener(rotateAnimationListener); + yield () => rotateAnimation.removeListener(rotateAnimationListener); + for (final e in _keyboardRotateAnimationManager) { + e.curve.addStatusListener((status) { + if (status.isAnimating) e.repeat.stop(); + if (status.isCompleted) e.repeat.repeat(); + }); + } } void _onPointerDown(PointerDownEvent event) { diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index 3992b56d3..5ee03c06d 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -1,21 +1,21 @@ -import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; -/// A callback function which takes as input the number of times the concerned -/// keyboard key has been pressed down & repeated ([KeyDownEvent] & -/// [KeyRepeatEvent]) and outputs the transformation that should be applied -/// -/// See the specific field in [KeyboardOptions] for the specific output meaning. -typedef KeyboardEffectSpeedCalculator = double Function(int repetitionCounter); - /// Options to configure how keyboard keys may be used to control the map /// +/// When a key is pushed down, an animation starts, consisting of a curved +/// portion which takes the animation to its maximum velocity, an indefinitely +/// long animation at maximum velocity, then ended on the key up with another +/// curved portion. If a key is pressed and released quickly, it might trigger a +/// short animation called a 'leap', which has the middle indefinite portion +/// ommitted. +/// /// See [CursorKeyboardRotationOptions] for options to control the keyboard and /// mouse cursor being used together to rotate the map. @immutable class KeyboardOptions { - /// Whether to allow arrow keys to pan the map (in their respective directions) + /// Whether to allow arrow keys to pan the map (in their respective + /// directions) /// /// This is enabled by default. final bool enableArrowKeysPanning; @@ -31,8 +31,8 @@ class KeyboardOptions { /// provide panning functionality easily for left handed users. final bool enableWASDPanning; - /// Whether to allow the Q & E keys (*) to rotate the map (Q rotates COUNTER- - /// CLOCKWISE, E rotates CLOCKWISE) + /// Whether to allow the Q & E keys (*) to rotate the map (Q rotates + /// anticlockwise, E rotates clockwise) /// /// QE are only the physical and logical keys on QWERTY keyboards. On non- /// QWERTY keyboards, such as AZERTY, the keys in the same position as on the @@ -47,29 +47,64 @@ class KeyboardOptions { /// QWERTY keyboard is used (ie. RF on AZERTY). final bool enableRFZooming; - /// Calculates the transformation to apply to the camera's position, where - /// the output is in logical pixels (the direction is automatically handled) + /// The maximum offset to apply per frame to the camera's center during a pan + /// animation, given the current camera zoom level + /// + /// Measured in screen space. It is not required to make use of the camera + /// zoom level. Negative numbers will flip the standard pan keys. + /// + /// Defaults to `10 * math.log(0.1 * z + 1) + 1`, where `z` is the zoom level. + final double Function(double zoom)? maxPanVelocity; + + /// The maximum zoom level difference to apply per frame to the camera's zoom + /// level during a zoom animation + /// + /// Measured in zoom levels. Negative numbers will flip the standard zoom + /// keys. + /// + /// Defaults to 0.05. + final double maxZoomVelocity; + + /// The maximum angular difference to apply per frame to the camera's rotation + /// during a rotation animation + /// + /// Measured in degrees. Negative numbers will flip the standard rotation + /// keys. /// - /// See [KeyboardEffectSpeedCalculator] for information. + /// Defaults to 3. + final double maxRotateVelocity; + + /// Duration of the curved ([Curves.easeIn]) portion of the animation occuring + /// after a key down event (and after a key up event if + /// [animationCurveReverseDuration] is `null`) /// - /// Defaults to [defaultPanSpeedCalculator]. - final KeyboardEffectSpeedCalculator? panSpeedCalculator; + /// Defaults to 500ms. + final Duration animationCurveDuration; - /// Calculates the transformation to apply to the camera's position, where - /// the output is in zoom levels (the direction is automatically handled) + /// Duration of the curved (reverse [Curves.easeIn]) portion of the animation + /// occuring after a key up event /// - /// See [KeyboardEffectSpeedCalculator] for information. + /// Defaults to 300ms. Set to `null` to use [animationCurveDuration]. + final Duration? animationCurveReverseDuration; + + /// Curve of the curved portion of the animation occuring after key down and + /// key up events /// - /// Defaults to [defaultZoomSpeedCalculator]. - final KeyboardEffectSpeedCalculator? zoomSpeedCalculator; + /// Defaults to [Curves.easeIn]. + final Curve animationCurveCurve; - /// Calculates the transformation to apply to the camera's position, where - /// the output is in degrees (the direction is automatically handled) + /// Maximum duration between the key down and key up events of an animation + /// which will trigger a 'leap' /// - /// See [KeyboardEffectSpeedCalculator] for information. + /// 'Leaping' allows the animation to reach its maximum velocity then animate + /// back to zero velocity, even when the animation key is not being held. + /// In other words, leaping occurs when one of the trigger keys is pressed - + /// not held - and pans/zooms/rotates the map a small amount. /// - /// Defaults to [defaultRotateSpeedCalculator]. - final KeyboardEffectSpeedCalculator? rotateSpeedCalculator; + /// The leap lasts for 2 * [animationCurveDuration]. + /// + /// Defaults to 150ms. Set to `null` to disable. + final Duration? performLeapTriggerDuration; /// Custom [FocusNode] to be used instead of internal node /// @@ -93,9 +128,13 @@ class KeyboardOptions { this.enableWASDPanning = false, this.enableQERotating = false, this.enableRFZooming = false, - this.panSpeedCalculator, - this.zoomSpeedCalculator, - this.rotateSpeedCalculator, + this.maxPanVelocity, + this.maxZoomVelocity = 0.05, + this.maxRotateVelocity = 3, + this.animationCurveDuration = const Duration(milliseconds: 500), + this.animationCurveReverseDuration = const Duration(milliseconds: 300), + this.animationCurveCurve = Curves.easeIn, + this.performLeapTriggerDuration = const Duration(milliseconds: 150), this.focusNode, this.autofocus = true, }); @@ -109,30 +148,4 @@ class KeyboardOptions { enableArrowKeysPanning: false, autofocus: false, ); - - /// The default [KeyboardOptions.panSpeedCalculator] - static double defaultPanSpeedCalculator(int counter) => switch (counter) { - 1 => 2, - <= 20 => 5, - <= 25 => 10, - <= 30 => 20, - <= 50 => 30, - <= 100 => 40, - _ => 50, - }; - - /// The default [KeyboardOptions.rotateSpeedCalculator] - static double defaultRotateSpeedCalculator(int counter) => switch (counter) { - 1 => 1, - <= 20 => 5, - _ => 10, - }; - - /// The default [KeyboardOptions.zoomSpeedCalculator] - static double defaultZoomSpeedCalculator(int counter) => switch (counter) { - 1 => 0.01, - <= 10 => 0.1, - <= 50 => 0.2, - _ => 0.5, - }; } From 41d15fec553b4660823462786a5e2220ec3142ad Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 12 Jan 2025 21:17:55 +0000 Subject: [PATCH 10/19] Respect updates to `KeyboardOptions` --- lib/src/gestures/map_interactive_viewer.dart | 84 ++++++++++++++++---- lib/src/map/options/interaction.dart | 4 +- lib/src/map/options/keyboard.dart | 36 +++++++++ 3 files changed, 107 insertions(+), 17 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index e53f3f58f..7b86d8935 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -97,7 +97,7 @@ class MapInteractiveViewerState extends State //! Keyboard animation late final FocusNode _keyboardListenerFocusNode; - late final List _keyboardListenersDisposal; + late List _keyboardListenersDisposal; var _panLeapCancelCompleter = Completer(); var _zoomLeapCancelCompleter = Completer(); var _rotateLeapCancelCompleter = Completer(); @@ -153,7 +153,7 @@ class MapInteractiveViewerState extends State 10 * math.log(0.1 * zoom + 1) + 1; late final _initialKeyboardPanAnimationMaxVelocity = _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); - late final _keyboardPanAnimationManager = _generateKeyboardAnimationManager( + late var _keyboardPanAnimationManager = _generateKeyboardAnimationManager( maxVelocities: [ Offset(0, -_initialKeyboardPanAnimationMaxVelocity), Offset(0, _initialKeyboardPanAnimationMaxVelocity), @@ -164,25 +164,21 @@ class MapInteractiveViewerState extends State ); // Keyboard animation > zoom - late final _keyboardZoomAnimationMaxVelocity = - _options.interactionOptions.keyboardOptions.maxZoomVelocity; - late final _keyboardZoomAnimationManager = + late var _keyboardZoomAnimationManager = _generateKeyboardAnimationManager( maxVelocities: [ - -_keyboardZoomAnimationMaxVelocity, - _keyboardZoomAnimationMaxVelocity, + -_options.interactionOptions.keyboardOptions.maxZoomVelocity, + _options.interactionOptions.keyboardOptions.maxZoomVelocity, ], zero: 0, ); // Keyboard animation > rotate - late final double _keyboardRotateAnimationMaxVelocity = - _options.interactionOptions.keyboardOptions.maxRotateVelocity; - late final _keyboardRotateAnimationManager = + late var _keyboardRotateAnimationManager = _generateKeyboardAnimationManager( maxVelocities: [ - -_keyboardRotateAnimationMaxVelocity, - _keyboardRotateAnimationMaxVelocity, + -_options.interactionOptions.keyboardOptions.maxRotateVelocity, + _options.interactionOptions.keyboardOptions.maxRotateVelocity, ], zero: 0, ); @@ -205,15 +201,14 @@ class MapInteractiveViewerState extends State ..addListener(_handleDoubleTapZoomAnimation) ..addStatusListener(_doubleTapZoomStatusListener); - _keyboardListenersDisposal = - _keyboardAnimationsHandler().toList(growable: false); - ServicesBinding.instance.keyboard .addHandler(cursorKeyboardRotationTriggerHandler); _keyboardListenerFocusNode = _interactionOptions.keyboardOptions.focusNode ?? FocusNode(debugLabel: 'FlutterMap'); + _keyboardListenersDisposal = + _keyboardAnimationsHandler().toList(growable: false); } @override @@ -236,7 +231,9 @@ class MapInteractiveViewerState extends State ServicesBinding.instance.keyboard .removeHandler(cursorKeyboardRotationTriggerHandler); - _keyboardListenerFocusNode.dispose(); + if (_options.interactionOptions.keyboardOptions.focusNode == null) { + _keyboardListenerFocusNode.dispose(); + } for (final e in _keyboardListenersDisposal) { e(); } @@ -360,6 +357,61 @@ class MapInteractiveViewerState extends State .removeHandler(cursorKeyboardRotationTriggerHandler); ServicesBinding.instance.keyboard .addHandler(cursorKeyboardRotationTriggerHandler); + + if (oldOptions.keyboardOptions != newOptions.keyboardOptions) { + for (final e in _keyboardListenersDisposal) { + e(); + } + for (final e in _keyboardPanAnimationManager) { + e.curve.stop(); + e.repeat.stop(); + e.curve.dispose(); + e.repeat.dispose(); + } + for (final e in _keyboardZoomAnimationManager) { + e.curve.stop(); + e.repeat.stop(); + e.curve.dispose(); + e.repeat.dispose(); + } + for (final e in _keyboardRotateAnimationManager) { + e.curve.stop(); + e.repeat.stop(); + e.curve.dispose(); + e.repeat.dispose(); + } + + final newKeyboardPanAnimationMaxVelocity = + _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); + _keyboardPanAnimationManager = _generateKeyboardAnimationManager( + maxVelocities: [ + Offset(0, -newKeyboardPanAnimationMaxVelocity), + Offset(0, newKeyboardPanAnimationMaxVelocity), + Offset(-newKeyboardPanAnimationMaxVelocity, 0), + Offset(newKeyboardPanAnimationMaxVelocity, 0), + ], + zero: Offset.zero, + ); + + _keyboardZoomAnimationManager = _generateKeyboardAnimationManager( + maxVelocities: [ + -_options.interactionOptions.keyboardOptions.maxZoomVelocity, + _options.interactionOptions.keyboardOptions.maxZoomVelocity, + ], + zero: 0, + ); + _keyboardRotateAnimationManager = + _generateKeyboardAnimationManager( + maxVelocities: [ + -_options.interactionOptions.keyboardOptions.maxRotateVelocity, + _options.interactionOptions.keyboardOptions.maxRotateVelocity, + ], + zero: 0, + ); + + _keyboardListenersDisposal = + _keyboardAnimationsHandler().toList(growable: false); + } } Map _createGestures({ diff --git a/lib/src/map/options/interaction.dart b/lib/src/map/options/interaction.dart index c4e10a85a..d4a79dcd4 100644 --- a/lib/src/map/options/interaction.dart +++ b/lib/src/map/options/interaction.dart @@ -130,7 +130,8 @@ final class InteractionOptions { pinchZoomWinGestures == other.pinchZoomWinGestures && pinchMoveThreshold == other.pinchMoveThreshold && pinchMoveWinGestures == other.pinchMoveWinGestures && - scrollWheelVelocity == other.scrollWheelVelocity; + scrollWheelVelocity == other.scrollWheelVelocity && + keyboardOptions == other.keyboardOptions; @override int get hashCode => Object.hash( @@ -144,5 +145,6 @@ final class InteractionOptions { pinchMoveThreshold, pinchMoveWinGestures, scrollWheelVelocity, + keyboardOptions, ); } diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index 5ee03c06d..02d664e0d 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -148,4 +148,40 @@ class KeyboardOptions { enableArrowKeysPanning: false, autofocus: false, ); + + @override + int get hashCode => Object.hash( + enableArrowKeysPanning, + enableWASDPanning, + enableQERotating, + enableRFZooming, + maxPanVelocity, + maxZoomVelocity, + maxRotateVelocity, + animationCurveDuration, + animationCurveReverseDuration, + animationCurveCurve, + performLeapTriggerDuration, + focusNode, + autofocus, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is KeyboardOptions && + enableArrowKeysPanning == other.enableArrowKeysPanning && + enableWASDPanning == other.enableWASDPanning && + enableQERotating == other.enableQERotating && + enableRFZooming == other.enableRFZooming && + maxPanVelocity == other.maxPanVelocity && + maxZoomVelocity == other.maxZoomVelocity && + maxRotateVelocity == other.maxRotateVelocity && + animationCurveDuration == other.animationCurveDuration && + animationCurveReverseDuration == + other.animationCurveReverseDuration && + animationCurveCurve == other.animationCurveCurve && + performLeapTriggerDuration == other.performLeapTriggerDuration && + focusNode == other.focusNode && + autofocus == other.autofocus); } From 085a7aeb53a462313cd0f8ed330bab3ec0dfeb57 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 12 Jan 2025 21:25:01 +0000 Subject: [PATCH 11/19] Minor scope change --- lib/src/gestures/compound_animations.dart | 6 +++--- lib/src/gestures/map_interactive_viewer.dart | 17 ++++++++--------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/lib/src/gestures/compound_animations.dart b/lib/src/gestures/compound_animations.dart index 6f906b66b..4a5bc1110 100644 --- a/lib/src/gestures/compound_animations.dart +++ b/lib/src/gestures/compound_animations.dart @@ -44,9 +44,9 @@ class _OffsetInfiniteSumAnimation extends CompoundAnimation Offset get value => first.value + next.value; } -@internal -class InfiniteAnimation extends CompoundAnimation with _InfiniteNotifier { - InfiniteAnimation(Animation repeat, Animation curve) +class _InfiniteAnimation extends CompoundAnimation + with _InfiniteNotifier { + _InfiniteAnimation(Animation repeat, Animation curve) : super(first: repeat, next: curve); @override diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 7b86d8935..7ea4a2caf 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -7,7 +7,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:latlong2/latlong.dart'; -import 'package:meta/meta.dart'; import 'package:vector_math/vector_math_64.dart'; part 'package:flutter_map/src/gestures/compound_animations.dart'; @@ -628,21 +627,21 @@ class MapInteractiveViewerState extends State Iterable _keyboardAnimationsHandler() sync* { final panAnimation = _OffsetInfiniteSumAnimation( - InfiniteAnimation( + _InfiniteAnimation( _keyboardPanAnimationManager[0].repeatAnimation, _keyboardPanAnimationManager[0].curveAnimation, ), _OffsetInfiniteSumAnimation( - InfiniteAnimation( + _InfiniteAnimation( _keyboardPanAnimationManager[1].repeatAnimation, _keyboardPanAnimationManager[1].curveAnimation, ), _OffsetInfiniteSumAnimation( - InfiniteAnimation( + _InfiniteAnimation( _keyboardPanAnimationManager[2].repeatAnimation, _keyboardPanAnimationManager[2].curveAnimation, ), - InfiniteAnimation( + _InfiniteAnimation( _keyboardPanAnimationManager[3].repeatAnimation, _keyboardPanAnimationManager[3].curveAnimation, ), @@ -670,11 +669,11 @@ class MapInteractiveViewerState extends State } final zoomAnimation = _NumInfiniteSumAnimation( - InfiniteAnimation( + _InfiniteAnimation( _keyboardZoomAnimationManager[0].repeatAnimation, _keyboardZoomAnimationManager[0].curveAnimation, ), - InfiniteAnimation( + _InfiniteAnimation( _keyboardZoomAnimationManager[1].repeatAnimation, _keyboardZoomAnimationManager[1].curveAnimation, ), @@ -698,11 +697,11 @@ class MapInteractiveViewerState extends State } final rotateAnimation = _NumInfiniteSumAnimation( - InfiniteAnimation( + _InfiniteAnimation( _keyboardRotateAnimationManager[0].repeatAnimation, _keyboardRotateAnimationManager[0].curveAnimation, ), - InfiniteAnimation( + _InfiniteAnimation( _keyboardRotateAnimationManager[1].repeatAnimation, _keyboardRotateAnimationManager[1].curveAnimation, ), From 96d0b2467871b43cbafb94f265a54f61d4fbded0 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Sun, 12 Jan 2025 21:37:34 +0000 Subject: [PATCH 12/19] Adjusted defaults --- lib/src/gestures/map_interactive_viewer.dart | 2 +- lib/src/map/options/keyboard.dart | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 7ea4a2caf..2f4c92991 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -149,7 +149,7 @@ class MapInteractiveViewerState extends State late var _keyboardAnimationPrevZoomLevel = _camera.zoom; double _keyboardPanAnimationMaxVelocityCalculator(double zoom) => _options.interactionOptions.keyboardOptions.maxPanVelocity?.call(zoom) ?? - 10 * math.log(0.1 * zoom + 1) + 1; + 12 * math.log(0.1 * zoom + 1) + 1; late final _initialKeyboardPanAnimationMaxVelocity = _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); late var _keyboardPanAnimationManager = _generateKeyboardAnimationManager( diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index 02d664e0d..20620d40e 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -53,7 +53,7 @@ class KeyboardOptions { /// Measured in screen space. It is not required to make use of the camera /// zoom level. Negative numbers will flip the standard pan keys. /// - /// Defaults to `10 * math.log(0.1 * z + 1) + 1`, where `z` is the zoom level. + /// Defaults to `12 * math.log(0.1 * z + 1) + 1`, where `z` is the zoom level. final double Function(double zoom)? maxPanVelocity; /// The maximum zoom level difference to apply per frame to the camera's zoom @@ -78,13 +78,13 @@ class KeyboardOptions { /// after a key down event (and after a key up event if /// [animationCurveReverseDuration] is `null`) /// - /// Defaults to 500ms. + /// Defaults to 700ms. final Duration animationCurveDuration; /// Duration of the curved (reverse [Curves.easeIn]) portion of the animation /// occuring after a key up event /// - /// Defaults to 300ms. Set to `null` to use [animationCurveDuration]. + /// Defaults to 500ms. Set to `null` to use [animationCurveDuration]. final Duration? animationCurveReverseDuration; /// Curve of the curved portion of the animation occuring after key down and @@ -131,8 +131,8 @@ class KeyboardOptions { this.maxPanVelocity, this.maxZoomVelocity = 0.05, this.maxRotateVelocity = 3, - this.animationCurveDuration = const Duration(milliseconds: 500), - this.animationCurveReverseDuration = const Duration(milliseconds: 300), + this.animationCurveDuration = const Duration(milliseconds: 700), + this.animationCurveReverseDuration = const Duration(milliseconds: 500), this.animationCurveCurve = Curves.easeIn, this.performLeapTriggerDuration = const Duration(milliseconds: 150), this.focusNode, From d099d0dac18c010a18660d1095a4ea5a0675d6d0 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 13 Jan 2025 22:51:07 +0000 Subject: [PATCH 13/19] Improved implementation (refactoring etc. to reduce duplication) Normalise pan offset per frame to ensure magnitude does not exceed max velocity --- lib/src/gestures/map_interactive_viewer.dart | 722 +++++++++---------- lib/src/map/options/keyboard.dart | 7 +- 2 files changed, 344 insertions(+), 385 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 2f4c92991..3072933f7 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -11,6 +11,17 @@ import 'package:vector_math/vector_math_64.dart'; part 'package:flutter_map/src/gestures/compound_animations.dart'; +typedef _AnimationManager = Map< + PhysicalKeyboardKey, + ({ + AnimationController curveController, + Animation curveAnimation, + Tween curveTween, + AnimationController repeatController, + Animation repeatAnimation, + Tween repeatTween, + })>; + /// The method signature of the builder. typedef InteractiveViewerBuilder = Widget Function( BuildContext context, @@ -94,98 +105,27 @@ class MapInteractiveViewerState extends State int _tapUpCounter = 0; Timer? _doubleTapHoldMaxDelay; - //! Keyboard animation + // Keyboard animation late final FocusNode _keyboardListenerFocusNode; late List _keyboardListenersDisposal; + var _panLeapCancelCompleter = Completer(); var _zoomLeapCancelCompleter = Completer(); var _rotateLeapCancelCompleter = Completer(); - List< - ({ - AnimationController curve, - Animation curveAnimation, - Tween curveTween, - AnimationController repeat, - Animation repeatAnimation, - Tween repeatTween, - })> _generateKeyboardAnimationManager({ - required List maxVelocities, - required T zero, - }) => - maxVelocities.map((end) { - final repeat = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 10), // always repeated - ); - final curve = AnimationController( - vsync: this, - duration: _options - .interactionOptions.keyboardOptions.animationCurveDuration, - reverseDuration: _options - .interactionOptions.keyboardOptions.animationCurveReverseDuration, - ); - final repeatTween = Tween(begin: end, end: end); - final curveTween = Tween(begin: zero, end: end); - - return ( - repeat: repeat, - curve: curve, - repeatTween: repeatTween, - curveTween: curveTween, - repeatAnimation: repeatTween.animate(repeat), - curveAnimation: curveTween - .chain( - CurveTween( - curve: _options - .interactionOptions.keyboardOptions.animationCurveCurve, - ), - ) - .animate(curve), - ); - }).toList(growable: false); - - // Keyboard animation > pan - late var _keyboardAnimationPrevZoomLevel = _camera.zoom; + late var _keyboardPanAnimationPrevZoom = _camera.zoom; // to detect changes + late double _keyboardPanAnimationMaxVelocity; double _keyboardPanAnimationMaxVelocityCalculator(double zoom) => - _options.interactionOptions.keyboardOptions.maxPanVelocity?.call(zoom) ?? + _interactionOptions.keyboardOptions.maxPanVelocity?.call(zoom) ?? 12 * math.log(0.1 * zoom + 1) + 1; - late final _initialKeyboardPanAnimationMaxVelocity = - _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); - late var _keyboardPanAnimationManager = _generateKeyboardAnimationManager( - maxVelocities: [ - Offset(0, -_initialKeyboardPanAnimationMaxVelocity), - Offset(0, _initialKeyboardPanAnimationMaxVelocity), - Offset(-_initialKeyboardPanAnimationMaxVelocity, 0), - Offset(_initialKeyboardPanAnimationMaxVelocity, 0), - ], - zero: Offset.zero, - ); - - // Keyboard animation > zoom - late var _keyboardZoomAnimationManager = - _generateKeyboardAnimationManager( - maxVelocities: [ - -_options.interactionOptions.keyboardOptions.maxZoomVelocity, - _options.interactionOptions.keyboardOptions.maxZoomVelocity, - ], - zero: 0, - ); - // Keyboard animation > rotate - late var _keyboardRotateAnimationManager = - _generateKeyboardAnimationManager( - maxVelocities: [ - -_options.interactionOptions.keyboardOptions.maxRotateVelocity, - _options.interactionOptions.keyboardOptions.maxRotateVelocity, - ], - zero: 0, - ); + late _AnimationManager _keyboardPanAnimationManager; + late _AnimationManager _keyboardZoomAnimationManager; + late _AnimationManager _keyboardRotateAnimationManager; + // Shortcuts MapCamera get _camera => widget.controller.camera; - MapOptions get _options => widget.controller.options; - InteractionOptions get _interactionOptions => _options.interactionOptions; @override @@ -206,8 +146,7 @@ class MapInteractiveViewerState extends State _keyboardListenerFocusNode = _interactionOptions.keyboardOptions.focusNode ?? FocusNode(debugLabel: 'FlutterMap'); - _keyboardListenersDisposal = - _keyboardAnimationsHandler().toList(growable: false); + _initKeyboardAnimations(); } @override @@ -230,50 +169,17 @@ class MapInteractiveViewerState extends State ServicesBinding.instance.keyboard .removeHandler(cursorKeyboardRotationTriggerHandler); - if (_options.interactionOptions.keyboardOptions.focusNode == null) { + if (_interactionOptions.keyboardOptions.focusNode == null) { _keyboardListenerFocusNode.dispose(); } - for (final e in _keyboardListenersDisposal) { - e(); - } - for (final e in _keyboardPanAnimationManager) { - e.curve.dispose(); - e.repeat.dispose(); - } - for (final e in _keyboardZoomAnimationManager) { - e.curve.dispose(); - e.repeat.dispose(); - } - for (final e in _keyboardRotateAnimationManager) { - e.curve.dispose(); - e.repeat.dispose(); - } + _disposeKeyboardAnimations(); super.dispose(); } /// Rebuilds the map widget void onMapStateChange() { - if (_keyboardAnimationPrevZoomLevel != _camera.zoom) { - _keyboardAnimationPrevZoomLevel = _camera.zoom; - - for (final (i, e) in _keyboardPanAnimationManager.indexed) { - final newMaxVelocity = - _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); - final end = switch (i) { - 0 => Offset(0, -newMaxVelocity), - 1 => Offset(0, newMaxVelocity), - 2 => Offset(-newMaxVelocity, 0), - 3 => Offset(newMaxVelocity, 0), - _ => throw StateError('Unpossible'), - }; - - e.repeatTween.begin = end; - e.repeatTween.end = end; - e.curveTween.end = end; - // curveTween.begin should always remain 0 - } - } + _updateKeyboardPanAnimationZoomLevel(); setState(() {}); } @@ -358,58 +264,8 @@ class MapInteractiveViewerState extends State .addHandler(cursorKeyboardRotationTriggerHandler); if (oldOptions.keyboardOptions != newOptions.keyboardOptions) { - for (final e in _keyboardListenersDisposal) { - e(); - } - for (final e in _keyboardPanAnimationManager) { - e.curve.stop(); - e.repeat.stop(); - e.curve.dispose(); - e.repeat.dispose(); - } - for (final e in _keyboardZoomAnimationManager) { - e.curve.stop(); - e.repeat.stop(); - e.curve.dispose(); - e.repeat.dispose(); - } - for (final e in _keyboardRotateAnimationManager) { - e.curve.stop(); - e.repeat.stop(); - e.curve.dispose(); - e.repeat.dispose(); - } - - final newKeyboardPanAnimationMaxVelocity = - _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); - _keyboardPanAnimationManager = _generateKeyboardAnimationManager( - maxVelocities: [ - Offset(0, -newKeyboardPanAnimationMaxVelocity), - Offset(0, newKeyboardPanAnimationMaxVelocity), - Offset(-newKeyboardPanAnimationMaxVelocity, 0), - Offset(newKeyboardPanAnimationMaxVelocity, 0), - ], - zero: Offset.zero, - ); - - _keyboardZoomAnimationManager = _generateKeyboardAnimationManager( - maxVelocities: [ - -_options.interactionOptions.keyboardOptions.maxZoomVelocity, - _options.interactionOptions.keyboardOptions.maxZoomVelocity, - ], - zero: 0, - ); - _keyboardRotateAnimationManager = - _generateKeyboardAnimationManager( - maxVelocities: [ - -_options.interactionOptions.keyboardOptions.maxRotateVelocity, - _options.interactionOptions.keyboardOptions.maxRotateVelocity, - ], - zero: 0, - ); - - _keyboardListenersDisposal = - _keyboardAnimationsHandler().toList(growable: false); + _disposeKeyboardAnimations(); + _initKeyboardAnimations(); } } @@ -514,216 +370,6 @@ class MapInteractiveViewerState extends State ); } - KeyEventResult _onKeyEvent(FocusNode _, KeyEvent evt) { - final keyboardOptions = _interactionOptions.keyboardOptions; - - void maybeLeap( - AnimationController curve, { - required Future cancelLeap, - }) { - if (keyboardOptions.performLeapTriggerDuration != null && - curve.lastElapsedDuration != null && - curve.lastElapsedDuration! < - keyboardOptions.performLeapTriggerDuration!) { - void leaper(AnimationStatus status) { - if (status == AnimationStatus.completed) { - curve.reverse(); - curve.removeStatusListener(leaper); - } - } - - curve.addStatusListener(leaper); - cancelLeap.then((_) => curve.removeStatusListener(leaper)); - } else { - curve.reverse(); - } - } - - if (keyboardOptions.enableArrowKeysPanning || - keyboardOptions.enableWASDPanning) { - final upKey = (keyboardOptions.enableArrowKeysPanning && - evt.physicalKey == PhysicalKeyboardKey.arrowUp) || - (keyboardOptions.enableWASDPanning && - evt.physicalKey == PhysicalKeyboardKey.keyW); - final downKey = (keyboardOptions.enableArrowKeysPanning && - evt.physicalKey == PhysicalKeyboardKey.arrowDown) || - (keyboardOptions.enableWASDPanning && - evt.physicalKey == PhysicalKeyboardKey.keyS); - final leftKey = (keyboardOptions.enableArrowKeysPanning && - evt.physicalKey == PhysicalKeyboardKey.arrowLeft) || - (keyboardOptions.enableWASDPanning && - evt.physicalKey == PhysicalKeyboardKey.keyA); - final rightKey = (keyboardOptions.enableArrowKeysPanning && - evt.physicalKey == PhysicalKeyboardKey.arrowRight) || - (keyboardOptions.enableWASDPanning && - evt.physicalKey == PhysicalKeyboardKey.keyD); - - if (upKey || downKey || leftKey || rightKey) { - final curve = _keyboardPanAnimationManager[upKey - ? 0 - : downKey - ? 1 - : leftKey - ? 2 - : 3] - .curve; - if (evt is KeyDownEvent) { - if (curve.isAnimating) { - _panLeapCancelCompleter.complete(); - _panLeapCancelCompleter = Completer(); - } - curve.forward(); - } - if (evt is KeyUpEvent) { - maybeLeap(curve, cancelLeap: _panLeapCancelCompleter.future); - } - return KeyEventResult.handled; - } - } - - if (keyboardOptions.enableRFZooming) { - final outKey = evt.physicalKey == PhysicalKeyboardKey.keyF; - final inKey = evt.physicalKey == PhysicalKeyboardKey.keyR; - - if (outKey || inKey) { - final curve = _keyboardZoomAnimationManager[outKey ? 0 : 1].curve; - if (evt is KeyDownEvent) { - if (curve.isAnimating) { - _zoomLeapCancelCompleter.complete(); - _zoomLeapCancelCompleter = Completer(); - } - curve.forward(); - } - if (evt is KeyUpEvent) { - maybeLeap(curve, cancelLeap: _zoomLeapCancelCompleter.future); - } - return KeyEventResult.handled; - } - } - - if (keyboardOptions.enableQERotating) { - final anticlockwiseKey = evt.physicalKey == PhysicalKeyboardKey.keyQ; - final clockwiseKey = evt.physicalKey == PhysicalKeyboardKey.keyE; - - if (anticlockwiseKey || clockwiseKey) { - final curve = - _keyboardRotateAnimationManager[anticlockwiseKey ? 0 : 1].curve; - if (evt is KeyDownEvent) { - if (curve.isAnimating) { - _rotateLeapCancelCompleter.complete(); - _rotateLeapCancelCompleter = Completer(); - } - curve.forward(); - } - if (evt is KeyUpEvent) { - maybeLeap(curve, cancelLeap: _rotateLeapCancelCompleter.future); - } - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; - } - - Iterable _keyboardAnimationsHandler() sync* { - final panAnimation = _OffsetInfiniteSumAnimation( - _InfiniteAnimation( - _keyboardPanAnimationManager[0].repeatAnimation, - _keyboardPanAnimationManager[0].curveAnimation, - ), - _OffsetInfiniteSumAnimation( - _InfiniteAnimation( - _keyboardPanAnimationManager[1].repeatAnimation, - _keyboardPanAnimationManager[1].curveAnimation, - ), - _OffsetInfiniteSumAnimation( - _InfiniteAnimation( - _keyboardPanAnimationManager[2].repeatAnimation, - _keyboardPanAnimationManager[2].curveAnimation, - ), - _InfiniteAnimation( - _keyboardPanAnimationManager[3].repeatAnimation, - _keyboardPanAnimationManager[3].curveAnimation, - ), - ), - ), - ); - void panAnimationListener() { - widget.controller.moveRaw( - _camera.screenOffsetToLatLng( - _camera.latLngToScreenOffset(_camera.center) + panAnimation.value, - ), - _camera.zoom, - hasGesture: true, - source: MapEventSource.keyboard, - ); - } - - panAnimation.addListener(panAnimationListener); - yield () => panAnimation.removeListener(panAnimationListener); - for (final e in _keyboardPanAnimationManager) { - e.curve.addStatusListener((status) { - if (status.isAnimating) e.repeat.stop(); - if (status.isCompleted) e.repeat.repeat(); - }); - } - - final zoomAnimation = _NumInfiniteSumAnimation( - _InfiniteAnimation( - _keyboardZoomAnimationManager[0].repeatAnimation, - _keyboardZoomAnimationManager[0].curveAnimation, - ), - _InfiniteAnimation( - _keyboardZoomAnimationManager[1].repeatAnimation, - _keyboardZoomAnimationManager[1].curveAnimation, - ), - ); - void zoomAnimationListener() { - widget.controller.moveRaw( - _camera.center, - _camera.zoom + zoomAnimation.value, - hasGesture: true, - source: MapEventSource.keyboard, - ); - } - - zoomAnimation.addListener(zoomAnimationListener); - yield () => zoomAnimation.removeListener(zoomAnimationListener); - for (final e in _keyboardZoomAnimationManager) { - e.curve.addStatusListener((status) { - if (status.isAnimating) e.repeat.stop(); - if (status.isCompleted) e.repeat.repeat(); - }); - } - - final rotateAnimation = _NumInfiniteSumAnimation( - _InfiniteAnimation( - _keyboardRotateAnimationManager[0].repeatAnimation, - _keyboardRotateAnimationManager[0].curveAnimation, - ), - _InfiniteAnimation( - _keyboardRotateAnimationManager[1].repeatAnimation, - _keyboardRotateAnimationManager[1].curveAnimation, - ), - ); - void rotateAnimationListener() { - widget.controller.rotateRaw( - _camera.rotation + rotateAnimation.value, - hasGesture: true, - source: MapEventSource.keyboard, - ); - } - - rotateAnimation.addListener(rotateAnimationListener); - yield () => rotateAnimation.removeListener(rotateAnimationListener); - for (final e in _keyboardRotateAnimationManager) { - e.curve.addStatusListener((status) { - if (status.isAnimating) e.repeat.stop(); - if (status.isCompleted) e.repeat.repeat(); - }); - } - } - void _onPointerDown(PointerDownEvent event) { ++_pointerCounter; @@ -964,7 +610,7 @@ class MapInteractiveViewerState extends State } if (!hasGestureRace || _gestureWinner != MultiFingerGesture.none) { - final gestures = _getMultiFingerGestureFlags(_options.interactionOptions); + final gestures = _getMultiFingerGestureFlags(_interactionOptions); final hasPinchZoom = InteractiveFlag.hasPinchZoom(_interactionOptions.flags) && @@ -1334,7 +980,6 @@ class MapInteractiveViewerState extends State } } - /// void _startListeningForAnimationInterruptions() { _isListeningForInterruptions = true; } @@ -1351,6 +996,319 @@ class MapInteractiveViewerState extends State } } + // Keyboard animations + + void _initKeyboardAnimations() { + _keyboardPanAnimationMaxVelocity = + _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); + _keyboardPanAnimationManager = _generateKeyboardAnimationManager( + maxVelocities: { + PhysicalKeyboardKey.arrowUp: + Offset(0, -_keyboardPanAnimationMaxVelocity), + PhysicalKeyboardKey.arrowDown: + Offset(0, _keyboardPanAnimationMaxVelocity), + PhysicalKeyboardKey.arrowLeft: + Offset(-_keyboardPanAnimationMaxVelocity, 0), + PhysicalKeyboardKey.arrowRight: + Offset(_keyboardPanAnimationMaxVelocity, 0), + }, + zero: Offset.zero, + ); + + _keyboardZoomAnimationManager = _generateKeyboardAnimationManager( + maxVelocities: { + PhysicalKeyboardKey.keyF: + -_interactionOptions.keyboardOptions.maxZoomVelocity, + PhysicalKeyboardKey.keyR: + _interactionOptions.keyboardOptions.maxZoomVelocity, + }, + zero: 0, + ); + + _keyboardRotateAnimationManager = _generateKeyboardAnimationManager( + maxVelocities: { + PhysicalKeyboardKey.keyQ: + -_interactionOptions.keyboardOptions.maxRotateVelocity, + PhysicalKeyboardKey.keyE: + _interactionOptions.keyboardOptions.maxRotateVelocity, + }, + zero: 0, + ); + + _keyboardListenersDisposal = + _keyboardAnimationsHandler().toList(growable: false); + } + + void _disposeKeyboardAnimations() { + for (final e in _keyboardListenersDisposal) { + e(); + } + for (final e in _keyboardPanAnimationManager.values) { + e.curveController.dispose(); + e.repeatController.dispose(); + } + for (final e in _keyboardZoomAnimationManager.values) { + e.curveController.dispose(); + e.repeatController.dispose(); + } + for (final e in _keyboardRotateAnimationManager.values) { + e.curveController.dispose(); + e.repeatController.dispose(); + } + } + + KeyEventResult _onKeyEvent(FocusNode _, KeyEvent evt) { + final keyboardOptions = _interactionOptions.keyboardOptions; + + void maybeLeap( + AnimationController curve, { + required Future cancelLeap, + }) { + if (keyboardOptions.performLeapTriggerDuration != null && + curve.lastElapsedDuration != null && + curve.lastElapsedDuration! < + keyboardOptions.performLeapTriggerDuration!) { + void leaper(AnimationStatus status) { + if (status == AnimationStatus.completed) { + curve.reverse(); + curve.removeStatusListener(leaper); + } + } + + curve.addStatusListener(leaper); + cancelLeap.then((_) => curve.removeStatusListener(leaper)); + } else { + curve.reverse(); + } + } + + late final panCurve = + _keyboardPanAnimationManager[switch (evt.physicalKey) { + PhysicalKeyboardKey.keyW when keyboardOptions.enableWASDPanning => + PhysicalKeyboardKey.arrowUp, + PhysicalKeyboardKey.keyA when keyboardOptions.enableWASDPanning => + PhysicalKeyboardKey.arrowLeft, + PhysicalKeyboardKey.keyS when keyboardOptions.enableWASDPanning => + PhysicalKeyboardKey.arrowDown, + PhysicalKeyboardKey.keyD when keyboardOptions.enableWASDPanning => + PhysicalKeyboardKey.arrowRight, + PhysicalKeyboardKey.arrowUp when keyboardOptions.enableArrowKeysPanning => + PhysicalKeyboardKey.arrowUp, + PhysicalKeyboardKey.arrowLeft + when keyboardOptions.enableArrowKeysPanning => + PhysicalKeyboardKey.arrowLeft, + PhysicalKeyboardKey.arrowDown + when keyboardOptions.enableArrowKeysPanning => + PhysicalKeyboardKey.arrowDown, + PhysicalKeyboardKey.arrowRight + when keyboardOptions.enableArrowKeysPanning => + PhysicalKeyboardKey.arrowRight, + _ => null, + }] + ?.curveController; + if (panCurve != null) { + if (evt is KeyDownEvent) { + if (panCurve.isAnimating) { + _panLeapCancelCompleter.complete(); + _panLeapCancelCompleter = Completer(); + } + panCurve.forward(); + } + if (evt is KeyUpEvent) { + maybeLeap(panCurve, cancelLeap: _panLeapCancelCompleter.future); + } + return KeyEventResult.handled; + } + + late final zoomCurve = + _keyboardZoomAnimationManager[evt.physicalKey]?.curveController; + if (keyboardOptions.enableRFZooming && zoomCurve != null) { + if (evt is KeyDownEvent) { + if (zoomCurve.isAnimating) { + _zoomLeapCancelCompleter.complete(); + _zoomLeapCancelCompleter = Completer(); + } + zoomCurve.forward(); + } + if (evt is KeyUpEvent) { + maybeLeap(zoomCurve, cancelLeap: _zoomLeapCancelCompleter.future); + } + return KeyEventResult.handled; + } + + late final rotateCurve = + _keyboardRotateAnimationManager[evt.physicalKey]?.curveController; + if (keyboardOptions.enableRFZooming && rotateCurve != null) { + if (evt is KeyDownEvent) { + if (rotateCurve.isAnimating) { + _rotateLeapCancelCompleter.complete(); + _rotateLeapCancelCompleter = Completer(); + } + rotateCurve.forward(); + } + if (evt is KeyUpEvent) { + maybeLeap(rotateCurve, cancelLeap: _rotateLeapCancelCompleter.future); + } + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + Iterable _keyboardAnimationsHandler() sync* { + Iterable initManagerListeners({ + required _AnimationManager manager, + required Animation Function(Animation a, Animation b) sum, + required void Function(T value) onTick, + }) sync* { + final animation = manager.values.fold>( + AlwaysStoppedAnimation(manager.values.first.curveTween.begin as T), + (v, e) => + sum(v, _InfiniteAnimation(e.repeatAnimation, e.curveAnimation)), + ); + + void animationListener() => onTick(animation.value); + + animation.addListener(animationListener); + yield () => animation.removeListener(animationListener); + + for (final direction in manager.values) { + void curveStatusListener(AnimationStatus status) { + if (status.isAnimating) direction.repeatController.stop(); + if (status.isCompleted) direction.repeatController.repeat(); + } + + direction.curveController.addStatusListener(curveStatusListener); + yield () => + direction.curveController.removeStatusListener(curveStatusListener); + } + } + + yield* initManagerListeners( + manager: _keyboardPanAnimationManager, + sum: _OffsetInfiniteSumAnimation.new, + onTick: (value) { + // Normalise so that the actual magnitude is not greater than max + // velocity + final offset = value.distanceSquared > + _keyboardPanAnimationMaxVelocity * + _keyboardPanAnimationMaxVelocity + ? value / (value.distance / _keyboardPanAnimationMaxVelocity) + : value; + + widget.controller.moveRaw( + _camera.screenOffsetToLatLng( + _camera.latLngToScreenOffset(_camera.center) + offset, + ), + _camera.zoom, + hasGesture: true, + source: MapEventSource.keyboard, + ); + }, + ); + + yield* initManagerListeners( + manager: _keyboardZoomAnimationManager, + sum: _NumInfiniteSumAnimation.new, + onTick: (value) => widget.controller.moveRaw( + _camera.center, + _camera.zoom + value, + hasGesture: true, + source: MapEventSource.keyboard, + ), + ); + + yield* initManagerListeners( + manager: _keyboardRotateAnimationManager, + sum: _NumInfiniteSumAnimation.new, + onTick: (value) => widget.controller.rotateRaw( + _camera.rotation + value, + hasGesture: true, + source: MapEventSource.keyboard, + ), + ); + } + + _AnimationManager _generateKeyboardAnimationManager({ + required Map maxVelocities, + required T zero, + }) => + Map.fromIterables( + maxVelocities.keys, + maxVelocities.values.map((end) { + final repeat = AnimationController( + vsync: this, + // We indefinitely repeat this animation, so the duration does not + // really matter + duration: const Duration(seconds: 1), + ); + final curve = AnimationController( + vsync: this, + duration: _options + .interactionOptions.keyboardOptions.animationCurveDuration, + reverseDuration: _options.interactionOptions.keyboardOptions + .animationCurveReverseDuration, + ); + + // We use a `Tween` here as it allows dynamically changing the `end`, + // which will cause it to animate to the new `end` implicitly + final repeatTween = Tween(begin: end, end: end); + final curveTween = Tween(begin: zero, end: end); + + return ( + repeatController: repeat, + curveController: curve, + // We expose the tweens so that they can theoretically be dynamically + // updated which will implicitly cause them to animate to the new + // value - we only actually do this for the pan animation (on zoom + // change) + repeatTween: repeatTween, + curveTween: curveTween, + repeatAnimation: repeatTween.animate(repeat), + curveAnimation: curveTween + .chain( + CurveTween( + curve: + _interactionOptions.keyboardOptions.animationCurveCurve, + ), + ) + .animate(curve), + ); + }), + ); + + void _updateKeyboardPanAnimationZoomLevel() { + if (_keyboardPanAnimationPrevZoom != _camera.zoom) { + _keyboardPanAnimationPrevZoom = _camera.zoom; + + _keyboardPanAnimationMaxVelocity = + _keyboardPanAnimationMaxVelocityCalculator(_camera.zoom); + + final up = _keyboardPanAnimationManager[PhysicalKeyboardKey.arrowUp]!; + up.repeatTween.begin = Offset(0, -_keyboardPanAnimationMaxVelocity); + up.repeatTween.end = Offset(0, -_keyboardPanAnimationMaxVelocity); + up.curveTween.end = Offset(0, -_keyboardPanAnimationMaxVelocity); + + final down = _keyboardPanAnimationManager[PhysicalKeyboardKey.arrowDown]!; + down.repeatTween.begin = Offset(0, _keyboardPanAnimationMaxVelocity); + down.repeatTween.end = Offset(0, _keyboardPanAnimationMaxVelocity); + down.curveTween.end = Offset(0, _keyboardPanAnimationMaxVelocity); + + final left = _keyboardPanAnimationManager[PhysicalKeyboardKey.arrowLeft]!; + left.repeatTween.begin = Offset(-_keyboardPanAnimationMaxVelocity, 0); + left.repeatTween.end = Offset(-_keyboardPanAnimationMaxVelocity, 0); + left.curveTween.end = Offset(-_keyboardPanAnimationMaxVelocity, 0); + + final right = + _keyboardPanAnimationManager[PhysicalKeyboardKey.arrowRight]!; + right.repeatTween.begin = Offset(_keyboardPanAnimationMaxVelocity, 0); + right.repeatTween.end = Offset(_keyboardPanAnimationMaxVelocity, 0); + right.curveTween.end = Offset(_keyboardPanAnimationMaxVelocity, 0); + } + } + + // Utilities + double _getZoomForScale(double startZoom, double scale) { final resultZoom = scale == 1.0 ? startZoom : startZoom + math.log(scale) / math.ln2; diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index 20620d40e..e52e408a8 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -101,9 +101,10 @@ class KeyboardOptions { /// In other words, leaping occurs when one of the trigger keys is pressed - /// not held - and pans/zooms/rotates the map a small amount. /// - /// The leap lasts for 2 * [animationCurveDuration]. + /// The leap lasts for [animationCurveDuration] + + /// [animationCurveReverseDuration]. /// - /// Defaults to 150ms. Set to `null` to disable. + /// Defaults to 100ms. Set to `null` to disable. final Duration? performLeapTriggerDuration; /// Custom [FocusNode] to be used instead of internal node @@ -134,7 +135,7 @@ class KeyboardOptions { this.animationCurveDuration = const Duration(milliseconds: 700), this.animationCurveReverseDuration = const Duration(milliseconds: 500), this.animationCurveCurve = Curves.easeIn, - this.performLeapTriggerDuration = const Duration(milliseconds: 150), + this.performLeapTriggerDuration = const Duration(milliseconds: 100), this.focusNode, this.autofocus = true, }); From 3b38701012751e199f0e512b371d04cc09c4df03 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 13 Jan 2025 22:59:55 +0000 Subject: [PATCH 14/19] Minor renaming --- lib/src/gestures/map_interactive_viewer.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 3072933f7..a4a8ea64a 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -11,7 +11,7 @@ import 'package:vector_math/vector_math_64.dart'; part 'package:flutter_map/src/gestures/compound_animations.dart'; -typedef _AnimationManager = Map< +typedef _KeyboardAnimationManager = Map< PhysicalKeyboardKey, ({ AnimationController curveController, @@ -119,9 +119,9 @@ class MapInteractiveViewerState extends State _interactionOptions.keyboardOptions.maxPanVelocity?.call(zoom) ?? 12 * math.log(0.1 * zoom + 1) + 1; - late _AnimationManager _keyboardPanAnimationManager; - late _AnimationManager _keyboardZoomAnimationManager; - late _AnimationManager _keyboardRotateAnimationManager; + late _KeyboardAnimationManager _keyboardPanAnimationManager; + late _KeyboardAnimationManager _keyboardZoomAnimationManager; + late _KeyboardAnimationManager _keyboardRotateAnimationManager; // Shortcuts MapCamera get _camera => widget.controller.camera; @@ -1157,7 +1157,7 @@ class MapInteractiveViewerState extends State Iterable _keyboardAnimationsHandler() sync* { Iterable initManagerListeners({ - required _AnimationManager manager, + required _KeyboardAnimationManager manager, required Animation Function(Animation a, Animation b) sum, required void Function(T value) onTick, }) sync* { @@ -1229,7 +1229,7 @@ class MapInteractiveViewerState extends State ); } - _AnimationManager _generateKeyboardAnimationManager({ + _KeyboardAnimationManager _generateKeyboardAnimationManager({ required Map maxVelocities, required T zero, }) => From 68315870f725542351b01df67bdbf27ee49804d7 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 20 Jan 2025 16:19:07 +0000 Subject: [PATCH 15/19] Minor improvements --- lib/src/gestures/map_events.dart | 2 +- lib/src/gestures/map_interactive_viewer.dart | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/src/gestures/map_events.dart b/lib/src/gestures/map_events.dart index 80f3da59d..9ed0f2c2b 100644 --- a/lib/src/gestures/map_events.dart +++ b/lib/src/gestures/map_events.dart @@ -67,7 +67,7 @@ enum MapEventSource { /// The [MapEvent] is caused by a CTRL + drag rotation gesture. cursorKeyboardRotation, - /// The [MapEvent] is caused by an arrow key on the keyboard panning the map. + /// The [MapEvent] is caused a keyboard key (see [KeyboardOptions]) keyboard, } diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index a4a8ea64a..392cf4ae7 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -1161,11 +1161,14 @@ class MapInteractiveViewerState extends State required Animation Function(Animation a, Animation b) sum, required void Function(T value) onTick, }) sync* { - final animation = manager.values.fold>( - AlwaysStoppedAnimation(manager.values.first.curveTween.begin as T), - (v, e) => - sum(v, _InfiniteAnimation(e.repeatAnimation, e.curveAnimation)), - ); + final animation = manager.values.skip(1).fold>( + _InfiniteAnimation( + manager.values.first.repeatAnimation, + manager.values.first.curveAnimation, + ), + (v, e) => + sum(v, _InfiniteAnimation(e.repeatAnimation, e.curveAnimation)), + ); void animationListener() => onTick(animation.value); From d14280e0bd8b22df85a1d90c5bb8bd38ad25ec9a Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 28 Jan 2025 11:31:14 +0000 Subject: [PATCH 16/19] Fixed issue where diagonal movement was still faster than axis-aligned movement Changed defaults --- lib/src/gestures/map_interactive_viewer.dart | 14 +++++++++----- lib/src/map/options/keyboard.dart | 16 ++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 392cf4ae7..cbe2aa253 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -1191,17 +1191,21 @@ class MapInteractiveViewerState extends State manager: _keyboardPanAnimationManager, sum: _OffsetInfiniteSumAnimation.new, onTick: (value) { - // Normalise so that the actual magnitude is not greater than max - // velocity - final offset = value.distanceSquared > + // Normalise & clamp so diagonal movement does not appear faster than + // axis-aligned movement. + // Note that one limitation of this implementation is that this is not + // curved. Therefore, it may appear that there is a 'snapping' effect + // when animating between axis-aligned and diagonal movement. + final correctedOffset = value.distanceSquared > _keyboardPanAnimationMaxVelocity * _keyboardPanAnimationMaxVelocity - ? value / (value.distance / _keyboardPanAnimationMaxVelocity) + ? (value / value.distance) * + (_keyboardPanAnimationMaxVelocity / math.sqrt(2)) : value; widget.controller.moveRaw( _camera.screenOffsetToLatLng( - _camera.latLngToScreenOffset(_camera.center) + offset, + _camera.latLngToScreenOffset(_camera.center) + correctedOffset, ), _camera.zoom, hasGesture: true, diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index e52e408a8..c3fd3e62f 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -62,7 +62,7 @@ class KeyboardOptions { /// Measured in zoom levels. Negative numbers will flip the standard zoom /// keys. /// - /// Defaults to 0.05. + /// Defaults to 0.03. final double maxZoomVelocity; /// The maximum angular difference to apply per frame to the camera's rotation @@ -78,19 +78,19 @@ class KeyboardOptions { /// after a key down event (and after a key up event if /// [animationCurveReverseDuration] is `null`) /// - /// Defaults to 700ms. + /// Defaults to 450ms. final Duration animationCurveDuration; /// Duration of the curved (reverse [Curves.easeIn]) portion of the animation /// occuring after a key up event /// - /// Defaults to 500ms. Set to `null` to use [animationCurveDuration]. + /// Defaults to 600ms. Set to `null` to use [animationCurveDuration]. final Duration? animationCurveReverseDuration; /// Curve of the curved portion of the animation occuring after key down and /// key up events /// - /// Defaults to [Curves.easeIn]. + /// Defaults to [Curves.easeInOut]. final Curve animationCurveCurve; /// Maximum duration between the key down and key up events of an animation @@ -130,11 +130,11 @@ class KeyboardOptions { this.enableQERotating = false, this.enableRFZooming = false, this.maxPanVelocity, - this.maxZoomVelocity = 0.05, + this.maxZoomVelocity = 0.03, this.maxRotateVelocity = 3, - this.animationCurveDuration = const Duration(milliseconds: 700), - this.animationCurveReverseDuration = const Duration(milliseconds: 500), - this.animationCurveCurve = Curves.easeIn, + this.animationCurveDuration = const Duration(milliseconds: 450), + this.animationCurveReverseDuration = const Duration(milliseconds: 600), + this.animationCurveCurve = Curves.easeInOut, this.performLeapTriggerDuration = const Duration(milliseconds: 100), this.focusNode, this.autofocus = true, From 29bb8b321ba0dec3175a6231f81fe5489973660d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 28 Jan 2025 12:36:01 +0000 Subject: [PATCH 17/19] Improved leaping support Fixed bug --- lib/src/gestures/map_interactive_viewer.dart | 72 +++++++++++++++----- lib/src/map/options/keyboard.dart | 45 +++++++++++- 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index cbe2aa253..ee02741cd 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -112,6 +112,9 @@ class MapInteractiveViewerState extends State var _panLeapCancelCompleter = Completer(); var _zoomLeapCancelCompleter = Completer(); var _rotateLeapCancelCompleter = Completer(); + final _panLeaping = ValueNotifier(false); + final _zoomLeaping = ValueNotifier(false); + final _rotateLeaping = ValueNotifier(false); late var _keyboardPanAnimationPrevZoom = _camera.zoom; // to detect changes late double _keyboardPanAnimationMaxVelocity; @@ -1063,20 +1066,27 @@ class MapInteractiveViewerState extends State void maybeLeap( AnimationController curve, { required Future cancelLeap, + required ValueNotifier leapingIndicator, }) { if (keyboardOptions.performLeapTriggerDuration != null && curve.lastElapsedDuration != null && curve.lastElapsedDuration! < keyboardOptions.performLeapTriggerDuration!) { - void leaper(AnimationStatus status) { - if (status == AnimationStatus.completed) { + void leaper() { + if (curve.value >= 0.6) { curve.reverse(); - curve.removeStatusListener(leaper); + curve.removeListener(leaper); + leapingIndicator.value = false; } } - curve.addStatusListener(leaper); - cancelLeap.then((_) => curve.removeStatusListener(leaper)); + leapingIndicator.value = true; + + curve.addListener(leaper); + cancelLeap.then((_) { + curve.removeListener(leaper); + leapingIndicator.value = false; + }); } else { curve.reverse(); } @@ -1115,7 +1125,11 @@ class MapInteractiveViewerState extends State panCurve.forward(); } if (evt is KeyUpEvent) { - maybeLeap(panCurve, cancelLeap: _panLeapCancelCompleter.future); + maybeLeap( + panCurve, + cancelLeap: _panLeapCancelCompleter.future, + leapingIndicator: _panLeaping, + ); } return KeyEventResult.handled; } @@ -1131,14 +1145,18 @@ class MapInteractiveViewerState extends State zoomCurve.forward(); } if (evt is KeyUpEvent) { - maybeLeap(zoomCurve, cancelLeap: _zoomLeapCancelCompleter.future); + maybeLeap( + zoomCurve, + cancelLeap: _zoomLeapCancelCompleter.future, + leapingIndicator: _zoomLeaping, + ); } return KeyEventResult.handled; } late final rotateCurve = _keyboardRotateAnimationManager[evt.physicalKey]?.curveController; - if (keyboardOptions.enableRFZooming && rotateCurve != null) { + if (keyboardOptions.enableQERotating && rotateCurve != null) { if (evt is KeyDownEvent) { if (rotateCurve.isAnimating) { _rotateLeapCancelCompleter.complete(); @@ -1147,7 +1165,11 @@ class MapInteractiveViewerState extends State rotateCurve.forward(); } if (evt is KeyUpEvent) { - maybeLeap(rotateCurve, cancelLeap: _rotateLeapCancelCompleter.future); + maybeLeap( + rotateCurve, + cancelLeap: _rotateLeapCancelCompleter.future, + leapingIndicator: _rotateLeaping, + ); } return KeyEventResult.handled; } @@ -1187,6 +1209,8 @@ class MapInteractiveViewerState extends State } } + final keyboardOptions = _interactionOptions.keyboardOptions; + yield* initManagerListeners( manager: _keyboardPanAnimationManager, sum: _OffsetInfiniteSumAnimation.new, @@ -1196,12 +1220,18 @@ class MapInteractiveViewerState extends State // Note that one limitation of this implementation is that this is not // curved. Therefore, it may appear that there is a 'snapping' effect // when animating between axis-aligned and diagonal movement. - final correctedOffset = value.distanceSquared > - _keyboardPanAnimationMaxVelocity * - _keyboardPanAnimationMaxVelocity - ? (value / value.distance) * - (_keyboardPanAnimationMaxVelocity / math.sqrt(2)) - : value; + var correctedOffset = value; + + if (value.distanceSquared > + _keyboardPanAnimationMaxVelocity * + _keyboardPanAnimationMaxVelocity) { + correctedOffset = (value / value.distance) * + (_keyboardPanAnimationMaxVelocity / math.sqrt(2)); + } + + if (_panLeaping.value) { + correctedOffset *= keyboardOptions.panLeapVelocityMultiplier; + } widget.controller.moveRaw( _camera.screenOffsetToLatLng( @@ -1219,7 +1249,11 @@ class MapInteractiveViewerState extends State sum: _NumInfiniteSumAnimation.new, onTick: (value) => widget.controller.moveRaw( _camera.center, - _camera.zoom + value, + _camera.zoom + + (value * + (_zoomLeaping.value + ? keyboardOptions.zoomLeapVelocityMultiplier + : 1)), hasGesture: true, source: MapEventSource.keyboard, ), @@ -1229,7 +1263,11 @@ class MapInteractiveViewerState extends State manager: _keyboardRotateAnimationManager, sum: _NumInfiniteSumAnimation.new, onTick: (value) => widget.controller.rotateRaw( - _camera.rotation + value, + _camera.rotation + + (value * + (_rotateLeaping.value + ? keyboardOptions.rotateLeapVelocityMultiplier + : 1)), hasGesture: true, source: MapEventSource.keyboard, ), diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index c3fd3e62f..72fe2a6e0 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -56,6 +56,16 @@ class KeyboardOptions { /// Defaults to `12 * math.log(0.1 * z + 1) + 1`, where `z` is the zoom level. final double Function(double zoom)? maxPanVelocity; + /// The amount to scale the panning offset velocity by during a leap animation + /// + /// The larger the number, the larger the movement during a leap. See + /// [performLeapTriggerDuration] for information about leaping. + /// + /// This may cause the pan velocity to exceed [maxPanVelocity]. + /// + /// Defaults to 3. + final double panLeapVelocityMultiplier; + /// The maximum zoom level difference to apply per frame to the camera's zoom /// level during a zoom animation /// @@ -65,6 +75,16 @@ class KeyboardOptions { /// Defaults to 0.03. final double maxZoomVelocity; + /// The amount to scale the zooming velocity by during a leap animation + /// + /// The larger the number, the larger the zoom difference during a leap. See + /// [performLeapTriggerDuration] for information about leaping. + /// + /// This may cause the pan velocity to exceed [maxZoomVelocity]. + /// + /// Defaults to 3. + final double zoomLeapVelocityMultiplier; + /// The maximum angular difference to apply per frame to the camera's rotation /// during a rotation animation /// @@ -74,6 +94,16 @@ class KeyboardOptions { /// Defaults to 3. final double maxRotateVelocity; + /// The amount to scale the rotation velocity by during a leap animation + /// + /// The larger the number, the larger the rotation difference during a leap. + /// See [performLeapTriggerDuration] for information about leaping. + /// + /// This may cause the pan velocity to exceed [maxRotateVelocity]. + /// + /// Defaults to 3. + final double rotateLeapVelocityMultiplier; + /// Duration of the curved ([Curves.easeIn]) portion of the animation occuring /// after a key down event (and after a key up event if /// [animationCurveReverseDuration] is `null`) @@ -101,10 +131,10 @@ class KeyboardOptions { /// In other words, leaping occurs when one of the trigger keys is pressed - /// not held - and pans/zooms/rotates the map a small amount. /// - /// The leap lasts for [animationCurveDuration] + - /// [animationCurveReverseDuration]. + /// The leap lasts for 3/4 * ([animationCurveDuration] + + /// [animationCurveReverseDuration]). /// - /// Defaults to 100ms. Set to `null` to disable. + /// Defaults to 100ms. Set to `null` to disable leaping. final Duration? performLeapTriggerDuration; /// Custom [FocusNode] to be used instead of internal node @@ -130,8 +160,11 @@ class KeyboardOptions { this.enableQERotating = false, this.enableRFZooming = false, this.maxPanVelocity, + this.panLeapVelocityMultiplier = 3, this.maxZoomVelocity = 0.03, + this.zoomLeapVelocityMultiplier = 3, this.maxRotateVelocity = 3, + this.rotateLeapVelocityMultiplier = 3, this.animationCurveDuration = const Duration(milliseconds: 450), this.animationCurveReverseDuration = const Duration(milliseconds: 600), this.animationCurveCurve = Curves.easeInOut, @@ -157,8 +190,11 @@ class KeyboardOptions { enableQERotating, enableRFZooming, maxPanVelocity, + panLeapVelocityMultiplier, maxZoomVelocity, + zoomLeapVelocityMultiplier, maxRotateVelocity, + rotateLeapVelocityMultiplier, animationCurveDuration, animationCurveReverseDuration, animationCurveCurve, @@ -176,8 +212,11 @@ class KeyboardOptions { enableQERotating == other.enableQERotating && enableRFZooming == other.enableRFZooming && maxPanVelocity == other.maxPanVelocity && + panLeapVelocityMultiplier == other.panLeapVelocityMultiplier && maxZoomVelocity == other.maxZoomVelocity && + zoomLeapVelocityMultiplier == other.zoomLeapVelocityMultiplier && maxRotateVelocity == other.maxRotateVelocity && + rotateLeapVelocityMultiplier == other.rotateLeapVelocityMultiplier && animationCurveDuration == other.animationCurveDuration && animationCurveReverseDuration == other.animationCurveReverseDuration && From 224cac157bf3051692e6835543bf33db9560be87 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 28 Jan 2025 22:06:43 +0000 Subject: [PATCH 18/19] Added `leapMaxOfCurveComponent` Improved documentation on `KeyboardOptions` and properties Changed defaults Minor improvements --- lib/src/gestures/map_interactive_viewer.dart | 126 +++++++++---------- lib/src/map/options/keyboard.dart | 53 +++++--- 2 files changed, 98 insertions(+), 81 deletions(-) diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index ee02741cd..e36fca209 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -112,15 +112,15 @@ class MapInteractiveViewerState extends State var _panLeapCancelCompleter = Completer(); var _zoomLeapCancelCompleter = Completer(); var _rotateLeapCancelCompleter = Completer(); - final _panLeaping = ValueNotifier(false); - final _zoomLeaping = ValueNotifier(false); - final _rotateLeaping = ValueNotifier(false); + final _isPanLeaping = ValueNotifier(false); + final _isZoomLeaping = ValueNotifier(false); + final _isRotateLeaping = ValueNotifier(false); late var _keyboardPanAnimationPrevZoom = _camera.zoom; // to detect changes late double _keyboardPanAnimationMaxVelocity; double _keyboardPanAnimationMaxVelocityCalculator(double zoom) => _interactionOptions.keyboardOptions.maxPanVelocity?.call(zoom) ?? - 12 * math.log(0.1 * zoom + 1) + 1; + 5 * math.log(0.15 * zoom + 1) + 1; late _KeyboardAnimationManager _keyboardPanAnimationManager; late _KeyboardAnimationManager _keyboardZoomAnimationManager; @@ -1068,28 +1068,29 @@ class MapInteractiveViewerState extends State required Future cancelLeap, required ValueNotifier leapingIndicator, }) { - if (keyboardOptions.performLeapTriggerDuration != null && - curve.lastElapsedDuration != null && - curve.lastElapsedDuration! < + if (keyboardOptions.performLeapTriggerDuration == null || + curve.lastElapsedDuration == null || + curve.lastElapsedDuration! > keyboardOptions.performLeapTriggerDuration!) { - void leaper() { - if (curve.value >= 0.6) { - curve.reverse(); - curve.removeListener(leaper); - leapingIndicator.value = false; - } - } - - leapingIndicator.value = true; + curve.reverse(); + return; + } - curve.addListener(leaper); - cancelLeap.then((_) { - curve.removeListener(leaper); + void listenForLeapCompletion() { + if (curve.value >= keyboardOptions.leapMaxOfCurveComponent) { + curve.reverse(); + curve.removeListener(listenForLeapCompletion); leapingIndicator.value = false; - }); - } else { - curve.reverse(); + } } + + curve.addListener(listenForLeapCompletion); + leapingIndicator.value = true; + + cancelLeap.then((_) { + curve.removeListener(listenForLeapCompletion); + leapingIndicator.value = false; + }); } late final panCurve = @@ -1128,7 +1129,7 @@ class MapInteractiveViewerState extends State maybeLeap( panCurve, cancelLeap: _panLeapCancelCompleter.future, - leapingIndicator: _panLeaping, + leapingIndicator: _isPanLeaping, ); } return KeyEventResult.handled; @@ -1148,7 +1149,7 @@ class MapInteractiveViewerState extends State maybeLeap( zoomCurve, cancelLeap: _zoomLeapCancelCompleter.future, - leapingIndicator: _zoomLeaping, + leapingIndicator: _isZoomLeaping, ); } return KeyEventResult.handled; @@ -1168,7 +1169,7 @@ class MapInteractiveViewerState extends State maybeLeap( rotateCurve, cancelLeap: _rotateLeapCancelCompleter.future, - leapingIndicator: _rotateLeaping, + leapingIndicator: _isRotateLeaping, ); } return KeyEventResult.handled; @@ -1178,11 +1179,11 @@ class MapInteractiveViewerState extends State } Iterable _keyboardAnimationsHandler() sync* { - Iterable initManagerListeners({ + VoidCallback initManagerListeners({ required _KeyboardAnimationManager manager, required Animation Function(Animation a, Animation b) sum, required void Function(T value) onTick, - }) sync* { + }) { final animation = manager.values.skip(1).fold>( _InfiniteAnimation( manager.values.first.repeatAnimation, @@ -1193,25 +1194,13 @@ class MapInteractiveViewerState extends State ); void animationListener() => onTick(animation.value); - animation.addListener(animationListener); - yield () => animation.removeListener(animationListener); - - for (final direction in manager.values) { - void curveStatusListener(AnimationStatus status) { - if (status.isAnimating) direction.repeatController.stop(); - if (status.isCompleted) direction.repeatController.repeat(); - } - - direction.curveController.addStatusListener(curveStatusListener); - yield () => - direction.curveController.removeStatusListener(curveStatusListener); - } + return () => animation.removeListener(animationListener); } final keyboardOptions = _interactionOptions.keyboardOptions; - yield* initManagerListeners( + yield initManagerListeners( manager: _keyboardPanAnimationManager, sum: _OffsetInfiniteSumAnimation.new, onTick: (value) { @@ -1229,7 +1218,7 @@ class MapInteractiveViewerState extends State (_keyboardPanAnimationMaxVelocity / math.sqrt(2)); } - if (_panLeaping.value) { + if (_isPanLeaping.value) { correctedOffset *= keyboardOptions.panLeapVelocityMultiplier; } @@ -1244,33 +1233,37 @@ class MapInteractiveViewerState extends State }, ); - yield* initManagerListeners( + yield initManagerListeners( manager: _keyboardZoomAnimationManager, sum: _NumInfiniteSumAnimation.new, - onTick: (value) => widget.controller.moveRaw( - _camera.center, - _camera.zoom + - (value * - (_zoomLeaping.value - ? keyboardOptions.zoomLeapVelocityMultiplier - : 1)), - hasGesture: true, - source: MapEventSource.keyboard, - ), + onTick: (value) { + if (_isZoomLeaping.value) { + value *= keyboardOptions.zoomLeapVelocityMultiplier; + } + + widget.controller.moveRaw( + _camera.center, + _camera.zoom + value, + hasGesture: true, + source: MapEventSource.keyboard, + ); + }, ); - yield* initManagerListeners( + yield initManagerListeners( manager: _keyboardRotateAnimationManager, sum: _NumInfiniteSumAnimation.new, - onTick: (value) => widget.controller.rotateRaw( - _camera.rotation + - (value * - (_rotateLeaping.value - ? keyboardOptions.rotateLeapVelocityMultiplier - : 1)), - hasGesture: true, - source: MapEventSource.keyboard, - ), + onTick: (value) { + if (_isRotateLeaping.value) { + value *= keyboardOptions.rotateLeapVelocityMultiplier; + } + + widget.controller.rotateRaw( + _camera.rotation + value, + hasGesture: true, + source: MapEventSource.keyboard, + ); + }, ); } @@ -1293,7 +1286,12 @@ class MapInteractiveViewerState extends State .interactionOptions.keyboardOptions.animationCurveDuration, reverseDuration: _options.interactionOptions.keyboardOptions .animationCurveReverseDuration, - ); + )..addStatusListener((status) { + // It's safe to add a listener here, because it is auto disposed + // when we dispose the animation controller + if (status.isAnimating) repeat.stop(); + if (status.isCompleted) repeat.repeat(); + }); // We use a `Tween` here as it allows dynamically changing the `end`, // which will cause it to animate to the new `end` implicitly diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index 72fe2a6e0..380faff4d 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -6,9 +6,11 @@ import 'package:flutter_map/flutter_map.dart'; /// When a key is pushed down, an animation starts, consisting of a curved /// portion which takes the animation to its maximum velocity, an indefinitely /// long animation at maximum velocity, then ended on the key up with another -/// curved portion. If a key is pressed and released quickly, it might trigger a -/// short animation called a 'leap', which has the middle indefinite portion -/// ommitted. +/// curved portion. +/// +/// If a key is pressed and released quickly, it might trigger a short animation +/// called a 'leap'. The leap consists of a part of the curved portion, and also +/// scales the velocity of the concerned gesture. /// /// See [CursorKeyboardRotationOptions] for options to control the keyboard and /// mouse cursor being used together to rotate the map. @@ -53,13 +55,13 @@ class KeyboardOptions { /// Measured in screen space. It is not required to make use of the camera /// zoom level. Negative numbers will flip the standard pan keys. /// - /// Defaults to `12 * math.log(0.1 * z + 1) + 1`, where `z` is the zoom level. + /// Defaults to `5 * math.log(0.15 * z + 1) + 1`, where `z` is the zoom level. final double Function(double zoom)? maxPanVelocity; /// The amount to scale the panning offset velocity by during a leap animation /// - /// The larger the number, the larger the movement during a leap. See - /// [performLeapTriggerDuration] for information about leaping. + /// The larger the number, the larger the movement during a leap. To change + /// the duration of a leap, see [leapMaxOfCurveComponent]. /// /// This may cause the pan velocity to exceed [maxPanVelocity]. /// @@ -77,8 +79,8 @@ class KeyboardOptions { /// The amount to scale the zooming velocity by during a leap animation /// - /// The larger the number, the larger the zoom difference during a leap. See - /// [performLeapTriggerDuration] for information about leaping. + /// The larger the number, the larger the zoom difference during a leap. To + /// change the duration of a leap, see [leapMaxOfCurveComponent]. /// /// This may cause the pan velocity to exceed [maxZoomVelocity]. /// @@ -97,7 +99,7 @@ class KeyboardOptions { /// The amount to scale the rotation velocity by during a leap animation /// /// The larger the number, the larger the rotation difference during a leap. - /// See [performLeapTriggerDuration] for information about leaping. + /// To change the duration of a leap, see [leapMaxOfCurveComponent]. /// /// This may cause the pan velocity to exceed [maxRotateVelocity]. /// @@ -126,17 +128,27 @@ class KeyboardOptions { /// Maximum duration between the key down and key up events of an animation /// which will trigger a 'leap' /// - /// 'Leaping' allows the animation to reach its maximum velocity then animate - /// back to zero velocity, even when the animation key is not being held. - /// In other words, leaping occurs when one of the trigger keys is pressed - - /// not held - and pans/zooms/rotates the map a small amount. - /// - /// The leap lasts for 3/4 * ([animationCurveDuration] + - /// [animationCurveReverseDuration]). + /// To customize the leap itself, see the [leapMaxOfCurveComponent] & + /// `...LeapVelocityMultiplier` properties. /// /// Defaults to 100ms. Set to `null` to disable leaping. final Duration? performLeapTriggerDuration; + /// The percentage (0.0 - 1.0) of the curve animation component that is driven + /// to (from 0), then in reverse from (to 0) + /// + /// Reducing means the leap occurs quicker (assuming a consistent curve + /// animation duration). Also see `...LeapVelocityMultiplier` properties to + /// change the distance of the leap assuming a consistent leap duration. + /// + /// For example, if set to 1, then the leap will take [animationCurveDuration] + /// + [animationCurveReverseDuration] to complete. + /// + /// Defaults to 0.6. Must be greater than 0 and less than or equal to 1. To + /// disable leaping, or change the maximum length of the key press that will + /// trigger a leap, see [performLeapTriggerDuration]. + final double leapMaxOfCurveComponent; + /// Custom [FocusNode] to be used instead of internal node /// /// May cause unexpected behaviour. @@ -169,9 +181,14 @@ class KeyboardOptions { this.animationCurveReverseDuration = const Duration(milliseconds: 600), this.animationCurveCurve = Curves.easeInOut, this.performLeapTriggerDuration = const Duration(milliseconds: 100), + this.leapMaxOfCurveComponent = 0.6, this.focusNode, this.autofocus = true, - }); + }) : assert( + leapMaxOfCurveComponent > 0 && leapMaxOfCurveComponent <= 1, + '`leapMaxOfCurveComponent` must be between 0 (exclusive) and 1 ' + '(inclusive)', + ); /// Disable keyboard control of the map /// @@ -199,6 +216,7 @@ class KeyboardOptions { animationCurveReverseDuration, animationCurveCurve, performLeapTriggerDuration, + leapMaxOfCurveComponent, focusNode, autofocus, ); @@ -222,6 +240,7 @@ class KeyboardOptions { other.animationCurveReverseDuration && animationCurveCurve == other.animationCurveCurve && performLeapTriggerDuration == other.performLeapTriggerDuration && + leapMaxOfCurveComponent == other.leapMaxOfCurveComponent && focusNode == other.focusNode && autofocus == other.autofocus); } From 6526fc1ecb0dfa96a8f67253e6b346288a371a0b Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 28 Jan 2025 22:35:51 +0000 Subject: [PATCH 19/19] Changed defaults --- lib/src/map/options/keyboard.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/src/map/options/keyboard.dart b/lib/src/map/options/keyboard.dart index 380faff4d..4002e1286 100644 --- a/lib/src/map/options/keyboard.dart +++ b/lib/src/map/options/keyboard.dart @@ -65,7 +65,7 @@ class KeyboardOptions { /// /// This may cause the pan velocity to exceed [maxPanVelocity]. /// - /// Defaults to 3. + /// Defaults to 5. final double panLeapVelocityMultiplier; /// The maximum zoom level difference to apply per frame to the camera's zoom @@ -172,7 +172,7 @@ class KeyboardOptions { this.enableQERotating = false, this.enableRFZooming = false, this.maxPanVelocity, - this.panLeapVelocityMultiplier = 3, + this.panLeapVelocityMultiplier = 5, this.maxZoomVelocity = 0.03, this.zoomLeapVelocityMultiplier = 3, this.maxRotateVelocity = 3, @@ -197,6 +197,7 @@ class KeyboardOptions { const KeyboardOptions.disabled() : this( enableArrowKeysPanning: false, + performLeapTriggerDuration: null, autofocus: false, );