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), + ], + ); } } diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 60be3d81e..acb68995f 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -57,5 +57,6 @@ 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'; diff --git a/lib/src/gestures/compound_animations.dart b/lib/src/gestures/compound_animations.dart new file mode 100644 index 000000000..4a5bc1110 --- /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; +} + +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_events.dart b/lib/src/gestures/map_events.dart index 2cad349bf..9ed0f2c2b 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 a keyboard key (see [KeyboardOptions]) + 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 46da7b911..e36fca209 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -9,6 +9,19 @@ import 'package:flutter_map/src/misc/extensions.dart'; import 'package:latlong2/latlong.dart'; import 'package:vector_math/vector_math_64.dart'; +part 'package:flutter_map/src/gestures/compound_animations.dart'; + +typedef _KeyboardAnimationManager = 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, @@ -92,10 +105,30 @@ class MapInteractiveViewerState extends State int _tapUpCounter = 0; Timer? _doubleTapHoldMaxDelay; - MapCamera get _camera => widget.controller.camera; + // Keyboard animation + late final FocusNode _keyboardListenerFocusNode; + late List _keyboardListenersDisposal; - MapOptions get _options => widget.controller.options; + var _panLeapCancelCompleter = Completer(); + var _zoomLeapCancelCompleter = Completer(); + var _rotateLeapCancelCompleter = Completer(); + 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) ?? + 5 * math.log(0.15 * zoom + 1) + 1; + + late _KeyboardAnimationManager _keyboardPanAnimationManager; + late _KeyboardAnimationManager _keyboardZoomAnimationManager; + late _KeyboardAnimationManager _keyboardRotateAnimationManager; + + // Shortcuts + MapCamera get _camera => widget.controller.camera; + MapOptions get _options => widget.controller.options; InteractionOptions get _interactionOptions => _options.interactionOptions; @override @@ -112,6 +145,11 @@ class MapInteractiveViewerState extends State ServicesBinding.instance.keyboard .addHandler(cursorKeyboardRotationTriggerHandler); + + _keyboardListenerFocusNode = + _interactionOptions.keyboardOptions.focusNode ?? + FocusNode(debugLabel: 'FlutterMap'); + _initKeyboardAnimations(); } @override @@ -134,11 +172,19 @@ class MapInteractiveViewerState extends State ServicesBinding.instance.keyboard .removeHandler(cursorKeyboardRotationTriggerHandler); + if (_interactionOptions.keyboardOptions.focusNode == null) { + _keyboardListenerFocusNode.dispose(); + } + _disposeKeyboardAnimations(); + super.dispose(); } /// Rebuilds the map widget - void onMapStateChange() => setState(() {}); + void onMapStateChange() { + _updateKeyboardPanAnimationZoomLevel(); + setState(() {}); + } /// Handles key down events to detect if one of the trigger keys got pressed. bool cursorKeyboardRotationTriggerHandler(KeyEvent event) { @@ -219,6 +265,11 @@ class MapInteractiveViewerState extends State .removeHandler(cursorKeyboardRotationTriggerHandler); ServicesBinding.instance.keyboard .addHandler(cursorKeyboardRotationTriggerHandler); + + if (oldOptions.keyboardOptions != newOptions.keyboardOptions) { + _disposeKeyboardAnimations(); + _initKeyboardAnimations(); + } } Map _createGestures({ @@ -287,29 +338,35 @@ 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 Focus( + debugLabel: 'FlutterMap', + autofocus: _interactionOptions.keyboardOptions.autofocus, + 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, + ), ), ), ), @@ -556,7 +613,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) && @@ -926,7 +983,6 @@ class MapInteractiveViewerState extends State } } - /// void _startListeningForAnimationInterruptions() { _isListeningForInterruptions = true; } @@ -943,6 +999,359 @@ 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, + required ValueNotifier leapingIndicator, + }) { + if (keyboardOptions.performLeapTriggerDuration == null || + curve.lastElapsedDuration == null || + curve.lastElapsedDuration! > + keyboardOptions.performLeapTriggerDuration!) { + curve.reverse(); + return; + } + + void listenForLeapCompletion() { + if (curve.value >= keyboardOptions.leapMaxOfCurveComponent) { + curve.reverse(); + curve.removeListener(listenForLeapCompletion); + leapingIndicator.value = false; + } + } + + curve.addListener(listenForLeapCompletion); + leapingIndicator.value = true; + + cancelLeap.then((_) { + curve.removeListener(listenForLeapCompletion); + leapingIndicator.value = false; + }); + } + + 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, + leapingIndicator: _isPanLeaping, + ); + } + 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, + leapingIndicator: _isZoomLeaping, + ); + } + return KeyEventResult.handled; + } + + late final rotateCurve = + _keyboardRotateAnimationManager[evt.physicalKey]?.curveController; + if (keyboardOptions.enableQERotating && rotateCurve != null) { + if (evt is KeyDownEvent) { + if (rotateCurve.isAnimating) { + _rotateLeapCancelCompleter.complete(); + _rotateLeapCancelCompleter = Completer(); + } + rotateCurve.forward(); + } + if (evt is KeyUpEvent) { + maybeLeap( + rotateCurve, + cancelLeap: _rotateLeapCancelCompleter.future, + leapingIndicator: _isRotateLeaping, + ); + } + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + Iterable _keyboardAnimationsHandler() sync* { + VoidCallback initManagerListeners({ + required _KeyboardAnimationManager manager, + required Animation Function(Animation a, Animation b) sum, + required void Function(T value) onTick, + }) { + 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); + animation.addListener(animationListener); + return () => animation.removeListener(animationListener); + } + + final keyboardOptions = _interactionOptions.keyboardOptions; + + yield initManagerListeners( + manager: _keyboardPanAnimationManager, + sum: _OffsetInfiniteSumAnimation.new, + onTick: (value) { + // 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. + var correctedOffset = value; + + if (value.distanceSquared > + _keyboardPanAnimationMaxVelocity * + _keyboardPanAnimationMaxVelocity) { + correctedOffset = (value / value.distance) * + (_keyboardPanAnimationMaxVelocity / math.sqrt(2)); + } + + if (_isPanLeaping.value) { + correctedOffset *= keyboardOptions.panLeapVelocityMultiplier; + } + + widget.controller.moveRaw( + _camera.screenOffsetToLatLng( + _camera.latLngToScreenOffset(_camera.center) + correctedOffset, + ), + _camera.zoom, + hasGesture: true, + source: MapEventSource.keyboard, + ); + }, + ); + + yield initManagerListeners( + manager: _keyboardZoomAnimationManager, + sum: _NumInfiniteSumAnimation.new, + onTick: (value) { + if (_isZoomLeaping.value) { + value *= keyboardOptions.zoomLeapVelocityMultiplier; + } + + widget.controller.moveRaw( + _camera.center, + _camera.zoom + value, + hasGesture: true, + source: MapEventSource.keyboard, + ); + }, + ); + + yield initManagerListeners( + manager: _keyboardRotateAnimationManager, + sum: _NumInfiniteSumAnimation.new, + onTick: (value) { + if (_isRotateLeaping.value) { + value *= keyboardOptions.rotateLeapVelocityMultiplier; + } + + widget.controller.rotateRaw( + _camera.rotation + value, + hasGesture: true, + source: MapEventSource.keyboard, + ); + }, + ); + } + + _KeyboardAnimationManager _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, + )..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 + 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/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index b0b8ad951..c7cdffd29 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -181,6 +181,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 3233fb660..d4a79dcd4 100644 --- a/lib/src/map/options/interaction.dart +++ b/lib/src/map/options/interaction.dart @@ -80,6 +80,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({ @@ -96,6 +104,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', @@ -121,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( @@ -135,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 new file mode 100644 index 000000000..4002e1286 --- /dev/null +++ b/lib/src/map/options/keyboard.dart @@ -0,0 +1,247 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; + +/// 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'. 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. +@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) + /// + /// 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 + /// 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 + /// 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; + + /// 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 `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. To change + /// the duration of a leap, see [leapMaxOfCurveComponent]. + /// + /// This may cause the pan velocity to exceed [maxPanVelocity]. + /// + /// Defaults to 5. + final double panLeapVelocityMultiplier; + + /// 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.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. To + /// change the duration of a leap, see [leapMaxOfCurveComponent]. + /// + /// 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 + /// + /// Measured in degrees. Negative numbers will flip the standard rotation + /// keys. + /// + /// 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. + /// To change the duration of a leap, see [leapMaxOfCurveComponent]. + /// + /// 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`) + /// + /// 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 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.easeInOut]. + final Curve animationCurveCurve; + + /// Maximum duration between the key down and key up events of an animation + /// which will trigger a 'leap' + /// + /// 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. + 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 + /// + /// 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.maxPanVelocity, + this.panLeapVelocityMultiplier = 5, + 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, + 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 + /// + /// [CursorKeyboardRotationOptions] may still be active, and is not disabled + /// if this is disabled. + const KeyboardOptions.disabled() + : this( + enableArrowKeysPanning: false, + performLeapTriggerDuration: null, + autofocus: false, + ); + + @override + int get hashCode => Object.hash( + enableArrowKeysPanning, + enableWASDPanning, + enableQERotating, + enableRFZooming, + maxPanVelocity, + panLeapVelocityMultiplier, + maxZoomVelocity, + zoomLeapVelocityMultiplier, + maxRotateVelocity, + rotateLeapVelocityMultiplier, + animationCurveDuration, + animationCurveReverseDuration, + animationCurveCurve, + performLeapTriggerDuration, + leapMaxOfCurveComponent, + 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 && + panLeapVelocityMultiplier == other.panLeapVelocityMultiplier && + maxZoomVelocity == other.maxZoomVelocity && + zoomLeapVelocityMultiplier == other.zoomLeapVelocityMultiplier && + maxRotateVelocity == other.maxRotateVelocity && + rotateLeapVelocityMultiplier == other.rotateLeapVelocityMultiplier && + animationCurveDuration == other.animationCurveDuration && + animationCurveReverseDuration == + other.animationCurveReverseDuration && + animationCurveCurve == other.animationCurveCurve && + performLeapTriggerDuration == other.performLeapTriggerDuration && + leapMaxOfCurveComponent == other.leapMaxOfCurveComponent && + focusNode == other.focusNode && + autofocus == other.autofocus); +}