diff --git a/example/lib/main.dart b/example/lib/main.dart index ae1a6c72a..8cb828dfb 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,8 +7,8 @@ import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart'; import 'package:flutter_map_example/pages/epsg3413_crs.dart'; import 'package:flutter_map_example/pages/epsg4326_crs.dart'; import 'package:flutter_map_example/pages/fallback_url_page.dart'; +import 'package:flutter_map_example/pages/gestures_page.dart'; import 'package:flutter_map_example/pages/home.dart'; -import 'package:flutter_map_example/pages/interactive_test_page.dart'; import 'package:flutter_map_example/pages/latlng_to_screen_point.dart'; import 'package:flutter_map_example/pages/many_circles.dart'; import 'package:flutter_map_example/pages/many_markers.dart'; @@ -76,7 +76,7 @@ class MyApp extends StatelessWidget { TileLoadingErrorHandle.route: (context) => const TileLoadingErrorHandle(), TileBuilderPage.route: (context) => const TileBuilderPage(), - InteractiveFlagsPage.route: (context) => const InteractiveFlagsPage(), + GesturesPage.route: (context) => const GesturesPage(), ManyMarkersPage.route: (context) => const ManyMarkersPage(), StatefulMarkersPage.route: (context) => const StatefulMarkersPage(), MapInsideListViewPage.route: (context) => const MapInsideListViewPage(), diff --git a/example/lib/pages/custom_crs/custom_crs.dart b/example/lib/pages/custom_crs/custom_crs.dart index 9d7589e35..da10af860 100644 --- a/example/lib/pages/custom_crs/custom_crs.dart +++ b/example/lib/pages/custom_crs/custom_crs.dart @@ -133,7 +133,7 @@ class CustomCrsPageState extends State { // Set maxZoom usually scales.length - 1 OR resolutions.length - 1 // but not greater maxZoom: maxZoom, - onTap: (tapPosition, p) => setState(() { + onTap: (_, p) => setState(() { initText = 'You clicked at'; point = proj4.Point(x: p.latitude, y: p.longitude); }), diff --git a/example/lib/pages/interactive_test_page.dart b/example/lib/pages/gestures_page.dart similarity index 72% rename from example/lib/pages/interactive_test_page.dart rename to example/lib/pages/gestures_page.dart index 8c0cb518b..6754c92b7 100644 --- a/example/lib/pages/interactive_test_page.dart +++ b/example/lib/pages/gestures_page.dart @@ -4,35 +4,35 @@ import 'package:flutter_map_example/misc/tile_providers.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; -class InteractiveFlagsPage extends StatefulWidget { - static const String route = '/interactive_flags_page'; +class GesturesPage extends StatefulWidget { + static const String route = '/enabled_gestures_page'; - const InteractiveFlagsPage({super.key}); + const GesturesPage({super.key}); @override - State createState() => _InteractiveFlagsPageState(); + State createState() => _GesturesPageState(); } -class _InteractiveFlagsPageState extends State { +class _GesturesPageState extends State { static const availableFlags = { 'Movement': { InteractiveFlag.drag: 'Drag', - InteractiveFlag.flingAnimation: 'Fling', - InteractiveFlag.pinchMove: 'Pinch', + InteractiveFlag.twoFingerMove: 'Two finger drag', }, 'Zooming': { - InteractiveFlag.pinchZoom: 'Pinch', + InteractiveFlag.twoFingerZoom: 'Pinch', InteractiveFlag.scrollWheelZoom: 'Scroll', - InteractiveFlag.doubleTapZoom: 'Double tap', - InteractiveFlag.doubleTapDragZoom: '+ drag', + InteractiveFlag.doubleTapZoomIn: 'Double tap', + InteractiveFlag.doubleTapDragZoom: 'Double tap+drag', + InteractiveFlag.trackpadZoom: 'Touchpad zoom', }, 'Rotation': { - InteractiveFlag.rotate: 'Twist', + InteractiveFlag.twoFingerRotate: 'Twist', + InteractiveFlag.keyTriggerDragRotate: 'CTRL+Drag', }, }; - int flags = InteractiveFlag.drag | InteractiveFlag.pinchZoom; - bool keyboardCursorRotate = false; + int flags = InteractiveFlag.drag | InteractiveFlag.twoFingerZoom; MapEvent? _latestEvent; @@ -40,14 +40,14 @@ class _InteractiveFlagsPageState extends State { Widget build(BuildContext context) { final screenWidth = MediaQuery.sizeOf(context).width; return Scaffold( - appBar: AppBar(title: const Text('Interactive Flags')), - drawer: const MenuDrawer(InteractiveFlagsPage.route), + appBar: AppBar(title: const Text('Input gestures')), + drawer: const MenuDrawer(GesturesPage.route), body: Padding( padding: const EdgeInsets.all(8), child: Column( children: [ Flex( - direction: screenWidth >= 600 ? Axis.horizontal : Axis.vertical, + direction: screenWidth >= 750 ? Axis.horizontal : Axis.vertical, mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: availableFlags.entries @@ -75,22 +75,10 @@ class _InteractiveFlagsPageState extends State { setState(() => flags |= e.key); }, ), - Text(e.value), + Text(e.value, textAlign: TextAlign.center), ], ), ), - if (category.key == 'Rotation') ...[ - Column( - children: [ - Checkbox.adaptive( - value: keyboardCursorRotate, - onChanged: (enabled) => setState( - () => keyboardCursorRotate = enabled!), - ), - const Text('Cursor & CTRL'), - ], - ), - ] ].interleave(const SizedBox(width: 12)).toList() ..removeLast(), ) @@ -117,18 +105,11 @@ class _InteractiveFlagsPageState extends State { Expanded( child: FlutterMap( options: MapOptions( - onMapEvent: (evt) => setState(() => _latestEvent = evt), + onMapEvent: (event) => setState(() => _latestEvent = event), initialCenter: const LatLng(51.5, -0.09), initialZoom: 11, interactionOptions: InteractionOptions( - flags: flags, - cursorKeyboardRotationOptions: - CursorKeyboardRotationOptions( - isKeyTrigger: (key) => - keyboardCursorRotate && - CursorKeyboardRotationOptions.defaultTriggerKeys - .contains(key), - ), + gestures: MapGestures.bitfield(flags), ), ), children: [openStreetMapTileLayer], @@ -178,6 +159,12 @@ class _InteractiveFlagsPageState extends State { return 'MapEventRotateEnd'; case MapEventNonRotatedSizeChange(): return 'MapEventNonRotatedSizeChange'; + case MapEventSecondaryLongPress(): + return 'MapEventSecondaryLongPress'; + case MapEventTertiaryTap(): + return 'MapEventTertiaryTap'; + case MapEventTertiaryLongPress(): + return 'MapEventTertiaryLongPress'; case null: return 'null'; default: diff --git a/example/lib/pages/latlng_to_screen_point.dart b/example/lib/pages/latlng_to_screen_point.dart index 4b35f2920..1676cf187 100644 --- a/example/lib/pages/latlng_to_screen_point.dart +++ b/example/lib/pages/latlng_to_screen_point.dart @@ -48,7 +48,10 @@ class _LatLngToScreenPointPageState extends State { initialCenter: const LatLng(51.5, -0.09), initialZoom: 11, interactionOptions: const InteractionOptions( - flags: ~InteractiveFlag.doubleTapZoom, + gestures: MapGestures.all( + doubleTapZoomIn: false, + doubleTapDragZoom: false, + ), ), onTap: (_, latLng) { final point = mapController.camera diff --git a/example/lib/pages/markers.dart b/example/lib/pages/markers.dart index 2f652096b..509805b2c 100644 --- a/example/lib/pages/markers.dart +++ b/example/lib/pages/markers.dart @@ -119,10 +119,14 @@ class _MarkerPageState extends State { options: MapOptions( initialCenter: const LatLng(51.5, -0.09), initialZoom: 5, - onTap: (_, p) => setState(() => customMarkers.add(buildPin(p))), + onTap: (_, p) { + setState(() => customMarkers.add(buildPin(p))); + }, interactionOptions: const InteractionOptions( - flags: ~InteractiveFlag.doubleTapZoom, - ), + gestures: MapGestures.all( + doubleTapDragZoom: false, + doubleTapZoomIn: false, + )), ), children: [ openStreetMapTileLayer, diff --git a/example/lib/pages/secondary_tap.dart b/example/lib/pages/secondary_tap.dart index 38a1db895..34deba32c 100644 --- a/example/lib/pages/secondary_tap.dart +++ b/example/lib/pages/secondary_tap.dart @@ -23,7 +23,7 @@ class SecondaryTapPage extends StatelessWidget { Flexible( child: FlutterMap( options: MapOptions( - onSecondaryTap: (tapPos, latLng) { + onSecondaryTap: (_, latLng) { ScaffoldMessenger.maybeOf(context)?.showSnackBar( SnackBar(content: Text('Secondary tap at $latLng')), ); diff --git a/example/lib/widgets/drawer/menu_drawer.dart b/example/lib/widgets/drawer/menu_drawer.dart index 1a31da9b7..4a88d5296 100644 --- a/example/lib/widgets/drawer/menu_drawer.dart +++ b/example/lib/widgets/drawer/menu_drawer.dart @@ -7,8 +7,8 @@ import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart'; import 'package:flutter_map_example/pages/epsg3413_crs.dart'; import 'package:flutter_map_example/pages/epsg4326_crs.dart'; import 'package:flutter_map_example/pages/fallback_url_page.dart'; +import 'package:flutter_map_example/pages/gestures_page.dart'; import 'package:flutter_map_example/pages/home.dart'; -import 'package:flutter_map_example/pages/interactive_test_page.dart'; import 'package:flutter_map_example/pages/latlng_to_screen_point.dart'; import 'package:flutter_map_example/pages/many_circles.dart'; import 'package:flutter_map_example/pages/many_markers.dart'; @@ -114,8 +114,8 @@ class MenuDrawer extends StatelessWidget { currentRoute: currentRoute, ), MenuItemWidget( - caption: 'Interactive Flags', - routeName: InteractiveFlagsPage.route, + caption: 'Map Gestures', + routeName: GesturesPage.route, currentRoute: currentRoute, ), const Divider(), diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 6e309bd07..9184488ca 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -18,11 +18,6 @@ library flutter_map; export 'package:flutter_map/src/geo/crs.dart' hide CrsWithStaticTransformation; export 'package:flutter_map/src/geo/latlng_bounds.dart'; -export 'package:flutter_map/src/gestures/interactive_flag.dart'; -export 'package:flutter_map/src/gestures/latlng_tween.dart'; -export 'package:flutter_map/src/gestures/map_events.dart'; -export 'package:flutter_map/src/gestures/multi_finger_gesture.dart'; -export 'package:flutter_map/src/gestures/positioned_tap_detector_2.dart'; export 'package:flutter_map/src/layer/attribution_layer/rich/animation.dart'; export 'package:flutter_map/src/layer/attribution_layer/rich/source.dart'; export 'package:flutter_map/src/layer/attribution_layer/rich/widget.dart'; @@ -51,11 +46,14 @@ export 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; export 'package:flutter_map/src/map/camera/camera.dart'; export 'package:flutter_map/src/map/camera/camera_constraint.dart'; export 'package:flutter_map/src/map/camera/camera_fit.dart'; +export 'package:flutter_map/src/map/controller/events/map_event_source.dart'; +export 'package:flutter_map/src/map/controller/events/map_events.dart'; 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/options.dart'; +export 'package:flutter_map/src/map/gestures/latlng_tween.dart'; +export 'package:flutter_map/src/map/options/interaction_options.dart'; +export 'package:flutter_map/src/map/options/map_gestures.dart'; +export 'package:flutter_map/src/map/options/map_options.dart'; export 'package:flutter_map/src/map/widget.dart'; export 'package:flutter_map/src/misc/bounds.dart'; export 'package:flutter_map/src/misc/extensions.dart'; diff --git a/lib/src/gestures/interactive_flag.dart b/lib/src/gestures/interactive_flag.dart deleted file mode 100644 index 92ad92b28..000000000 --- a/lib/src/gestures/interactive_flag.dart +++ /dev/null @@ -1,98 +0,0 @@ -/// Use [InteractiveFlag] to disable / enable certain events Use -/// [InteractiveFlag.all] to enable all events, use [InteractiveFlag.none] to -/// disable all events -/// -/// If you want mix interactions for example drag and rotate interactions then -/// you have two options: -/// a. Add your own flags: [InteractiveFlag.drag] | [InteractiveFlag.rotate] -/// b. Remove unnecessary flags from all: -/// [InteractiveFlag.all] & -/// ~[InteractiveFlag.flingAnimation] & -/// ~[InteractiveFlag.pinchMove] & -/// ~[InteractiveFlag.pinchZoom] & -/// ~[InteractiveFlag.doubleTapZoom] -abstract class InteractiveFlag { - const InteractiveFlag._(); - - /// All available interactive flags, use as `flags: InteractiveFlag.all` to - /// enable all gestures. - static const int all = drag | - flingAnimation | - pinchMove | - pinchZoom | - doubleTapZoom | - doubleTapDragZoom | - scrollWheelZoom | - rotate; - - static const int none = 0; - - /// Enable panning with a single finger or cursor - static const int drag = 1 << 0; - - /// Enable fling animation after panning if velocity is great enough. - static const int flingAnimation = 1 << 1; - - /// Enable panning with multiple fingers - static const int pinchMove = 1 << 2; - - /// Enable zooming with a multi-finger pinch gesture - static const int pinchZoom = 1 << 3; - - /// Enable zooming with a single-finger double tap gesture - static const int doubleTapZoom = 1 << 4; - - /// Enable zooming with a single-finger double-tap-drag gesture - /// - /// The associated [MapEventSource] is [MapEventSource.doubleTapHold]. - static const int doubleTapDragZoom = 1 << 5; - - /// Enable zooming with a mouse scroll wheel - static const int scrollWheelZoom = 1 << 6; - - /// Enable rotation with two-finger twist gesture - /// - /// For controlling cursor/keyboard rotation, see - /// [InteractionOptions.cursorKeyboardRotationOptions]. - static const int rotate = 1 << 7; - - /// Flags pertaining to gestures which require multiple fingers. - static const _multiFingerFlags = pinchMove | pinchZoom | rotate; - - /// Returns `true` if [leftFlags] has at least one member in [rightFlags] - /// (intersection) for example [leftFlags]= [InteractiveFlag.drag] | - /// [InteractiveFlag.rotate] and [rightFlags]= [InteractiveFlag.rotate] | - /// [InteractiveFlag.flingAnimation] returns true because both have - /// [InteractiveFlag.rotate] flag - static bool hasFlag(int leftFlags, int rightFlags) { - return leftFlags & rightFlags != 0; - } - - /// True if any multi-finger gesture flags are enabled. - static bool hasMultiFinger(int flags) => hasFlag(flags, _multiFingerFlags); - - /// True if the [drag] interactive flag is enabled. - static bool hasDrag(int flags) => hasFlag(flags, drag); - - /// True if the [flingAnimation] interactive flag is enabled. - static bool hasFlingAnimation(int flags) => hasFlag(flags, flingAnimation); - - /// True if the [pinchMove] interactive flag is enabled. - static bool hasPinchMove(int flags) => hasFlag(flags, pinchMove); - - /// True if the [pinchZoom] interactive flag is enabled. - static bool hasPinchZoom(int flags) => hasFlag(flags, pinchZoom); - - /// True if the [doubleTapDragZoom] interactive flag is enabled. - static bool hasDoubleTapDragZoom(int flags) => - hasFlag(flags, doubleTapDragZoom); - - /// True if the [doubleTapZoom] interactive flag is enabled. - static bool hasDoubleTapZoom(int flags) => hasFlag(flags, doubleTapZoom); - - /// True if the [rotate] interactive flag is enabled. - static bool hasRotate(int flags) => hasFlag(flags, rotate); - - /// True if the [scrollWheelZoom] interactive flag is enabled. - static bool hasScrollWheelZoom(int flags) => hasFlag(flags, scrollWheelZoom); -} diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart deleted file mode 100644 index 57ece9a10..000000000 --- a/lib/src/gestures/map_interactive_viewer.dart +++ /dev/null @@ -1,943 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:vector_math/vector_math_64.dart'; - -/// The method signature of the builder. -typedef InteractiveViewerBuilder = Widget Function( - BuildContext context, - MapOptions options, - MapCamera camera, -); - -/// Applies interactions (gestures/scroll/taps etc) to the current [MapCamera] -/// via the internal [controller]. -class MapInteractiveViewer extends StatefulWidget { - /// The [InteractiveViewerBuilder] - final InteractiveViewerBuilder builder; - - /// Reference to the [MapControllerImpl]. - final MapControllerImpl controller; - - /// Create a new [MapInteractiveViewer] instance. - const MapInteractiveViewer({ - super.key, - required this.builder, - required this.controller, - }); - - @override - State createState() => MapInteractiveViewerState(); -} - -/// The widget state for the [MapInteractiveViewer]. -class MapInteractiveViewerState extends State - with TickerProviderStateMixin { - static const int _kMinFlingVelocity = 800; - static const _kDoubleTapZoomDuration = 200; - - /// The maximum delay between to taps to be counted as a double tap. - static const doubleTapDelay = Duration(milliseconds: 250); - - final _positionedTapController = PositionedTapController(); - final _gestureArenaTeam = GestureArenaTeam(); - late Map _gestures; - - bool _dragMode = false; - int _gestureWinner = MultiFingerGesture.none; - int _pointerCounter = 0; - bool _isListeningForInterruptions = false; - - var _rotationStarted = false; - var _pinchZoomStarted = false; - var _pinchMoveStarted = false; - var _dragStarted = false; - var _flingAnimationStarted = false; - - /// Helps to reset ScaleUpdateDetails.scale back to 1.0 when a multi finger - /// gesture wins - late double _scaleCorrector; - late double _lastRotation; - late double _lastScale; - late Offset _lastFocalLocal; - late LatLng _mapCenterStart; - late double _mapZoomStart; - late Offset _focalStartLocal; - late LatLng _focalStartLatLng; - - late final AnimationController _flingController = - AnimationController(vsync: this); - late Animation _flingAnimation; - - late final AnimationController _doubleTapController = AnimationController( - vsync: this, - duration: const Duration( - milliseconds: _kDoubleTapZoomDuration, - ), - ); - late Animation _doubleTapZoomAnimation; - late Animation _doubleTapCenterAnimation; - - // 'ckr' = cursor/keyboard rotation - final _ckrTriggered = ValueNotifier(false); - double _ckrClickDegrees = 0; - double _ckrInitialDegrees = 0; - - int _tapUpCounter = 0; - Timer? _doubleTapHoldMaxDelay; - - MapCamera get _camera => widget.controller.camera; - - MapOptions get _options => widget.controller.options; - - InteractionOptions get _interactionOptions => _options.interactionOptions; - - @override - void initState() { - super.initState(); - widget.controller.interactiveViewerState = this; - widget.controller.addListener(onMapStateChange); - _flingController - ..addListener(_handleFlingAnimation) - ..addStatusListener(_flingAnimationStatusListener); - _doubleTapController - ..addListener(_handleDoubleTapZoomAnimation) - ..addStatusListener(_doubleTapZoomStatusListener); - - ServicesBinding.instance.keyboard - .addHandler(cursorKeyboardRotationTriggerHandler); - } - - @override - void didChangeDependencies() { - // _createGestures uses a MediaQuery to determine gesture settings. This - // will update those gesture settings if they change. - _gestures = _createGestures( - dragEnabled: InteractiveFlag.hasDrag(_interactionOptions.flags), - ); - super.didChangeDependencies(); - } - - @override - void dispose() { - widget.controller.removeListener(onMapStateChange); - _flingController.dispose(); - _doubleTapController.dispose(); - - _ckrTriggered.dispose(); - ServicesBinding.instance.keyboard - .removeHandler(cursorKeyboardRotationTriggerHandler); - - super.dispose(); - } - - void onMapStateChange() => setState(() {}); - - bool cursorKeyboardRotationTriggerHandler(KeyEvent event) { - _ckrTriggered.value = (event is KeyRepeatEvent || event is KeyDownEvent) && - (_interactionOptions.cursorKeyboardRotationOptions.isKeyTrigger ?? - (key) => CursorKeyboardRotationOptions.defaultTriggerKeys - .contains(key))(event.logicalKey); - return false; - } - - /// Perform all required actions when the [InteractionOptions] have changed. - void updateGestures( - InteractionOptions oldOptions, - InteractionOptions newOptions, - ) { - final newHasDrag = InteractiveFlag.hasDrag(newOptions.flags); - if (newHasDrag != InteractiveFlag.hasDrag(oldOptions.flags)) { - _gestures = _createGestures(dragEnabled: newHasDrag); - } - - if (!InteractiveFlag.hasFlingAnimation(newOptions.flags)) { - _closeFlingAnimationController(MapEventSource.interactiveFlagsChanged); - } - if (InteractiveFlag.hasDoubleTapZoom(newOptions.flags)) { - _closeDoubleTapController(MapEventSource.interactiveFlagsChanged); - } - - final gestures = _getMultiFingerGestureFlags(newOptions); - - if (_rotationStarted && - !InteractiveFlag.hasRotate(newOptions.flags) && - !MultiFingerGesture.hasRotate(gestures)) { - _rotationStarted = false; - - if (_gestureWinner == MultiFingerGesture.rotate) { - _gestureWinner = MultiFingerGesture.none; - } - - widget.controller.rotateEnded(MapEventSource.interactiveFlagsChanged); - } - - var emitMapEventMoveEnd = false; - - if (_pinchZoomStarted && - !InteractiveFlag.hasPinchZoom(newOptions.flags) && - !MultiFingerGesture.hasPinchZoom(gestures)) { - _pinchZoomStarted = false; - emitMapEventMoveEnd = true; - - if (_gestureWinner == MultiFingerGesture.pinchZoom) { - _gestureWinner = MultiFingerGesture.none; - } - } - - if (_pinchMoveStarted && - !InteractiveFlag.hasPinchMove(newOptions.flags) && - !MultiFingerGesture.hasPinchMove(gestures)) { - _pinchMoveStarted = false; - emitMapEventMoveEnd = true; - - if (_gestureWinner == MultiFingerGesture.pinchMove) { - _gestureWinner = MultiFingerGesture.none; - } - } - - if (_dragStarted && !newHasDrag) { - _dragStarted = false; - emitMapEventMoveEnd = true; - } - - if (emitMapEventMoveEnd) { - widget.controller.moveEnded(MapEventSource.interactiveFlagsChanged); - } - - // No way to detect whether the [CursorKeyboardRotationOptions.isKeyTrigger]s - // are equal, so assume they aren't - ServicesBinding.instance.keyboard - .removeHandler(cursorKeyboardRotationTriggerHandler); - ServicesBinding.instance.keyboard - .addHandler(cursorKeyboardRotationTriggerHandler); - } - - Map _createGestures({ - required bool dragEnabled, - }) { - final gestureSettings = MediaQuery.gestureSettingsOf(context); - return { - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(debugOwner: this), - (recognizer) { - recognizer - ..onTapDown = _positionedTapController.onTapDown - ..onTapUp = _handleOnTapUp - ..onTap = _positionedTapController.onTap - ..onSecondaryTap = _positionedTapController.onSecondaryTap - ..onSecondaryTapDown = _positionedTapController.onTapDown; - }, - ), - LongPressGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => LongPressGestureRecognizer(debugOwner: this), - (recognizer) { - recognizer.onLongPress = _positionedTapController.onLongPress; - }, - ), - if (dragEnabled) - VerticalDragGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => VerticalDragGestureRecognizer(debugOwner: this), - (recognizer) { - recognizer - ..gestureSettings = gestureSettings - ..team ??= _gestureArenaTeam - ..onUpdate = (details) { - // Absorbing vertical drags - }; - }, - ), - if (dragEnabled) - HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers< - HorizontalDragGestureRecognizer>( - () => HorizontalDragGestureRecognizer(debugOwner: this), - (recognizer) { - recognizer - ..gestureSettings = gestureSettings - ..team ??= _gestureArenaTeam - ..onUpdate = (details) { - // Absorbing horizontal drags - }; - }), - ScaleGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => ScaleGestureRecognizer(debugOwner: this), - (recognizer) { - recognizer - ..onStart = _handleScaleStart - ..onUpdate = _handleScaleUpdate - ..onEnd = _handleScaleEnd - ..team ??= _gestureArenaTeam; - _gestureArenaTeam.captain = recognizer; - }, - ), - }; - } - - @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, - ), - ), - ), - ); - } - - void _onPointerDown(PointerDownEvent event) { - ++_pointerCounter; - - if (_ckrTriggered.value) { - _ckrInitialDegrees = _camera.rotation; - _ckrClickDegrees = getCursorRotationDegrees(event.localPosition); - widget.controller.rotateStarted(MapEventSource.cursorKeyboardRotation); - } - - if (_options.onPointerDown != null) { - final latlng = _camera.offsetToCrs(event.localPosition); - _options.onPointerDown!(event, latlng); - } - } - - void _onPointerUp(PointerUpEvent event) { - --_pointerCounter; - - if (_interactionOptions.cursorKeyboardRotationOptions.setNorthOnClick && - _ckrTriggered.value && - _ckrInitialDegrees == _camera.rotation) { - widget.controller.rotateRaw( - getCursorRotationDegrees(event.localPosition), - hasGesture: true, - source: MapEventSource.cursorKeyboardRotation, - ); - } - - if (_options.onPointerUp != null) { - final latlng = _camera.offsetToCrs(event.localPosition); - _options.onPointerUp!(event, latlng); - } - } - - void _onPointerCancel(PointerCancelEvent event) { - --_pointerCounter; - - if (_options.onPointerCancel != null) { - final latlng = _camera.offsetToCrs(event.localPosition); - _options.onPointerCancel!(event, latlng); - } - } - - void _onPointerHover(PointerHoverEvent event) { - if (_options.onPointerHover != null) { - final latlng = _camera.offsetToCrs(event.localPosition); - _options.onPointerHover!(event, latlng); - } - } - - void _onPointerMove(PointerMoveEvent event) { - if (!_ckrTriggered.value) return; - - final baseSetNorth = - getCursorRotationDegrees(event.localPosition) - _ckrClickDegrees; - - widget.controller.rotateRaw( - _interactionOptions.cursorKeyboardRotationOptions.behaviour == - CursorRotationBehaviour.setNorth - ? baseSetNorth - : (_ckrInitialDegrees + baseSetNorth) % 360, - hasGesture: true, - source: MapEventSource.cursorKeyboardRotation, - ); - - if (_interactionOptions.cursorKeyboardRotationOptions.behaviour == - CursorRotationBehaviour.setNorth) _ckrClickDegrees = 0; - } - - void _onPointerSignal(PointerSignalEvent pointerSignal) { - // Handle mouse scroll events if the enableScrollWheel parameter is enabled - if (pointerSignal is PointerScrollEvent && - InteractiveFlag.hasScrollWheelZoom(_interactionOptions.flags) && - pointerSignal.scrollDelta.dy != 0) { - // Prevent scrolling of parent/child widgets simultaneously. See - // [PointerSignalResolver] documentation for more information. - GestureBinding.instance.pointerSignalResolver.register( - pointerSignal, - (pointerSignal) { - pointerSignal as PointerScrollEvent; - final minZoom = _options.minZoom ?? 0.0; - final maxZoom = _options.maxZoom ?? double.infinity; - final newZoom = (_camera.zoom - - pointerSignal.scrollDelta.dy * - _interactionOptions.scrollWheelVelocity) - .clamp(minZoom, maxZoom); - // Calculate offset of mouse cursor from viewport center - final newCenter = _camera.focusedZoomCenter( - pointerSignal.localPosition.toPoint(), - newZoom, - ); - widget.controller.moveRaw( - newCenter, - newZoom, - hasGesture: true, - source: MapEventSource.scrollWheel, - ); - }, - ); - } - } - - int _getMultiFingerGestureFlags(InteractionOptions interactionOptions) { - if (interactionOptions.enableMultiFingerGestureRace) { - if (_gestureWinner == MultiFingerGesture.pinchZoom) { - return interactionOptions.pinchZoomWinGestures; - } else if (_gestureWinner == MultiFingerGesture.rotate) { - return interactionOptions.rotationWinGestures; - } else if (_gestureWinner == MultiFingerGesture.pinchMove) { - return interactionOptions.pinchMoveWinGestures; - } - - return MultiFingerGesture.none; - } else { - return MultiFingerGesture.all; - } - } - - /// Thanks to https://stackoverflow.com/questions/48916517/javascript-click-and-drag-to-rotate - double getCursorRotationDegrees(Offset offset) { - const correctionTerm = 180; // North = cursor - - final size = MediaQuery.sizeOf(context); - return (-math.atan2( - offset.dx - size.width / 2, offset.dy - size.height / 2) * - (180 / math.pi)) + - correctionTerm; - } - - void _closeFlingAnimationController(MapEventSource source) { - _flingAnimationStarted = false; - if (_flingController.isAnimating) { - _flingController.stop(); - - _stopListeningForAnimationInterruptions(); - - widget.controller.flingEnded(source); - } - } - - void _closeDoubleTapController(MapEventSource source) { - if (_doubleTapController.isAnimating) { - _doubleTapController.stop(); - - _stopListeningForAnimationInterruptions(); - - widget.controller.doubleTapZoomEnded(source); - } - } - - void _handleScaleStart(ScaleStartDetails details) { - _dragMode = _pointerCounter == 1; - - final eventSource = _dragMode - ? MapEventSource.dragStart - : MapEventSource.multiFingerGestureStart; - _closeFlingAnimationController(eventSource); - _closeDoubleTapController(eventSource); - - _gestureWinner = MultiFingerGesture.none; - - _mapZoomStart = _camera.zoom; - _mapCenterStart = _camera.center; - _focalStartLocal = _lastFocalLocal = details.localFocalPoint; - _focalStartLatLng = _camera.offsetToCrs(_focalStartLocal); - - _dragStarted = false; - _pinchZoomStarted = false; - _pinchMoveStarted = false; - _rotationStarted = false; - - _lastRotation = 0.0; - _scaleCorrector = 0.0; - _lastScale = 1.0; - } - - void _handleScaleUpdate(ScaleUpdateDetails details) { - if (_tapUpCounter == 1) { - _handleDoubleTapHold(details); - return; - } - - final currentRotation = details.rotation * radians2Degrees; - if (_dragMode) { - _handleScaleDragUpdate(details); - } else if (InteractiveFlag.hasMultiFinger(_interactionOptions.flags)) { - _handleScaleMultiFingerUpdate(details, currentRotation); - } - - _lastRotation = currentRotation; - _lastScale = details.scale; - _lastFocalLocal = details.localFocalPoint; - } - - void _handleScaleDragUpdate(ScaleUpdateDetails details) { - if (_ckrTriggered.value) return; - - const eventSource = MapEventSource.onDrag; - - if (InteractiveFlag.hasDrag(_interactionOptions.flags)) { - if (!_dragStarted) { - // We could emit start event at [handleScaleStart], however it is - // possible drag will be disabled during ongoing drag then - // [didUpdateWidget] will emit MapEventMoveEnd and if drag is enabled - // again then this will emit the start event again. - _dragStarted = true; - widget.controller.moveStarted(eventSource); - } - - final localDistanceOffset = _rotateOffset( - _lastFocalLocal - details.localFocalPoint, - ); - - widget.controller.dragUpdated(eventSource, localDistanceOffset); - } - } - - void _handleScaleMultiFingerUpdate( - ScaleUpdateDetails details, - double currentRotation, - ) { - final hasGestureRace = _interactionOptions.enableMultiFingerGestureRace; - - if (hasGestureRace && _gestureWinner == MultiFingerGesture.none) { - final gestureWinner = _determineMultiFingerGestureWinner( - _interactionOptions.rotationThreshold, - currentRotation, - details.scale, - details.localFocalPoint, - ); - if (gestureWinner != null) { - _gestureWinner = gestureWinner; - // note: here we could reset to current values instead of last values - _scaleCorrector = 1.0 - _lastScale; - } - } - - if (!hasGestureRace || _gestureWinner != MultiFingerGesture.none) { - final gestures = _getMultiFingerGestureFlags(_options.interactionOptions); - - final hasPinchZoom = - InteractiveFlag.hasPinchZoom(_interactionOptions.flags) && - MultiFingerGesture.hasPinchZoom(gestures); - final hasPinchMove = - InteractiveFlag.hasPinchMove(_interactionOptions.flags) && - MultiFingerGesture.hasPinchMove(gestures); - if (hasPinchZoom || hasPinchMove) { - _handleScalePinchZoomAndMove(details, hasPinchZoom, hasPinchMove); - } - - if (InteractiveFlag.hasRotate(_interactionOptions.flags) && - MultiFingerGesture.hasRotate(gestures)) { - _handleScalePinchRotate(details, currentRotation); - } - } - } - - void _handleScalePinchZoomAndMove( - ScaleUpdateDetails details, - bool hasPinchZoom, - bool hasPinchMove, - ) { - var newCenter = _camera.center; - var newZoom = _camera.zoom; - - // Handle pinch zoom. - if (hasPinchZoom && details.scale > 0.0) { - newZoom = _getZoomForScale( - _mapZoomStart, - details.scale + _scaleCorrector, - ); - - // Handle starting of pinch zoom. - if (!_pinchZoomStarted && newZoom != _mapZoomStart) { - _pinchZoomStarted = true; - - if (!_pinchMoveStarted) { - // We want to call moveStart only once for a movement so don't call - // it if a pinch move is already underway. - widget.controller.moveStarted(MapEventSource.onMultiFinger); - } - } - } - - // Handle pinch move. - if (hasPinchMove) { - newCenter = _calculatePinchZoomAndMove(details, newZoom); - - if (!_pinchMoveStarted && _lastFocalLocal != details.localFocalPoint) { - _pinchMoveStarted = true; - - if (!_pinchZoomStarted) { - // We want to call moveStart only once for a movement so don't call - // it if a pinch zoom is already underway. - widget.controller.moveStarted(MapEventSource.onMultiFinger); - } - } - } - - if (_pinchZoomStarted || _pinchMoveStarted) { - widget.controller.moveRaw( - newCenter, - newZoom, - hasGesture: true, - source: MapEventSource.onMultiFinger, - ); - } - } - - LatLng _calculatePinchZoomAndMove( - ScaleUpdateDetails details, - double zoomAfterPinchZoom, - ) { - final oldCenterPt = _camera.project(_camera.center, zoomAfterPinchZoom); - final newFocalLatLong = - _camera.offsetToCrs(_focalStartLocal, zoomAfterPinchZoom); - final newFocalPt = _camera.project(newFocalLatLong, zoomAfterPinchZoom); - final oldFocalPt = _camera.project(_focalStartLatLng, zoomAfterPinchZoom); - final zoomDifference = oldFocalPt - newFocalPt; - final moveDifference = _rotateOffset(_focalStartLocal - _lastFocalLocal); - - final newCenterPt = oldCenterPt + zoomDifference + moveDifference.toPoint(); - return _camera.unproject(newCenterPt, zoomAfterPinchZoom); - } - - void _handleScalePinchRotate( - ScaleUpdateDetails details, - double currentRotation, - ) { - if (!_rotationStarted && currentRotation != 0.0) { - _rotationStarted = true; - widget.controller.rotateStarted(MapEventSource.onMultiFinger); - } - - if (_rotationStarted) { - final rotationDiff = currentRotation - _lastRotation; - final oldCenterPt = _camera.project(_camera.center); - final rotationCenter = - _camera.project(_camera.offsetToCrs(_lastFocalLocal)); - final vector = oldCenterPt - rotationCenter; - final rotatedVector = vector.rotate(degrees2Radians * rotationDiff); - final newCenter = rotationCenter + rotatedVector; - - widget.controller.moveAndRotateRaw( - _camera.unproject(newCenter), - _camera.zoom, - _camera.rotation + rotationDiff, - offset: Offset.zero, - hasGesture: true, - source: MapEventSource.onMultiFinger, - ); - } - } - - int? _determineMultiFingerGestureWinner(double rotationThreshold, - double currentRotation, double scale, Offset focalOffset) { - final int winner; - if (InteractiveFlag.hasPinchZoom(_interactionOptions.flags) && - (_getZoomForScale(_mapZoomStart, scale) - _mapZoomStart).abs() >= - _interactionOptions.pinchZoomThreshold) { - if (_interactionOptions.debugMultiFingerGestureWinner) { - debugPrint('Multi Finger Gesture winner: Pinch Zoom'); - } - winner = MultiFingerGesture.pinchZoom; - } else if (InteractiveFlag.hasRotate(_interactionOptions.flags) && - currentRotation.abs() >= rotationThreshold) { - if (_interactionOptions.debugMultiFingerGestureWinner) { - debugPrint('Multi Finger Gesture winner: Rotate'); - } - winner = MultiFingerGesture.rotate; - } else if (InteractiveFlag.hasPinchMove(_interactionOptions.flags) && - (_focalStartLocal - focalOffset).distance >= - _interactionOptions.pinchMoveThreshold) { - if (_interactionOptions.debugMultiFingerGestureWinner) { - debugPrint('Multi Finger Gesture winner: Pinch Move'); - } - winner = MultiFingerGesture.pinchMove; - } else { - return null; - } - - return winner; - } - - void _handleScaleEnd(ScaleEndDetails details) { - _resetDoubleTapHold(); - - final eventSource = - _dragMode ? MapEventSource.dragEnd : MapEventSource.multiFingerEnd; - - if (_rotationStarted) { - _rotationStarted = false; - widget.controller.rotateEnded(eventSource); - } - - if (_dragStarted || _pinchZoomStarted || _pinchMoveStarted) { - _dragStarted = _pinchZoomStarted = _pinchMoveStarted = false; - widget.controller.moveEnded(eventSource); - } - - // Prevent pan fling if rotation via keyboard/pointer is in progress - if (_ckrTriggered.value) return; - - final hasFling = - InteractiveFlag.hasFlingAnimation(_interactionOptions.flags); - - final magnitude = details.velocity.pixelsPerSecond.distance; - if (magnitude < _kMinFlingVelocity || !hasFling) { - if (hasFling) widget.controller.flingNotStarted(eventSource); - return; - } - - final direction = details.velocity.pixelsPerSecond / magnitude; - final distance = - (Offset.zero & Size(_camera.nonRotatedSize.x, _camera.nonRotatedSize.y)) - .shortestSide; - - final flingOffset = _focalStartLocal - _lastFocalLocal; - _flingAnimation = Tween( - begin: flingOffset, - end: flingOffset - direction * distance, - ).animate(_flingController); - - _flingController - ..value = 0.0 - ..fling( - velocity: magnitude / 1000.0, - springDescription: SpringDescription.withDampingRatio( - mass: 1, - stiffness: 1000, - ratio: 5, - )); - } - - void _handleTap(TapPosition position) { - if (_ckrTriggered.value) return; - - _closeFlingAnimationController(MapEventSource.tap); - _closeDoubleTapController(MapEventSource.tap); - - final relativePosition = position.relative; - if (relativePosition == null) return; - - widget.controller.tapped( - MapEventSource.tap, - position, - _camera.offsetToCrs(relativePosition), - ); - } - - void _handleSecondaryTap(TapPosition position) { - _closeFlingAnimationController(MapEventSource.secondaryTap); - _closeDoubleTapController(MapEventSource.secondaryTap); - - final relativePosition = position.relative; - if (relativePosition == null) return; - - widget.controller.secondaryTapped( - MapEventSource.secondaryTap, - position, - _camera.offsetToCrs(relativePosition), - ); - } - - void _handleLongPress(TapPosition position) { - if (_ckrTriggered.value) return; - - _resetDoubleTapHold(); - - _closeFlingAnimationController(MapEventSource.longPress); - _closeDoubleTapController(MapEventSource.longPress); - - widget.controller.longPressed( - MapEventSource.longPress, - position, - _camera.offsetToCrs(position.relative!), - ); - } - - void _handleDoubleTap(TapPosition tapPosition) { - _resetDoubleTapHold(); - - _closeFlingAnimationController(MapEventSource.doubleTap); - _closeDoubleTapController(MapEventSource.doubleTap); - - if (InteractiveFlag.hasDoubleTapZoom(_interactionOptions.flags)) { - final newZoom = _getZoomForScale(_camera.zoom, 2); - final newCenter = _camera.focusedZoomCenter( - tapPosition.relative!.toPoint(), - newZoom, - ); - _startDoubleTapAnimation(newZoom, newCenter); - } - } - - void _startDoubleTapAnimation(double newZoom, LatLng newCenter) { - _doubleTapZoomAnimation = Tween(begin: _camera.zoom, end: newZoom) - .chain(CurveTween(curve: Curves.linear)) - .animate(_doubleTapController); - _doubleTapCenterAnimation = - LatLngTween(begin: _camera.center, end: newCenter) - .chain(CurveTween(curve: Curves.linear)) - .animate(_doubleTapController); - _doubleTapController.forward(from: 0); - } - - void _doubleTapZoomStatusListener(AnimationStatus status) { - if (status == AnimationStatus.forward) { - widget.controller.doubleTapZoomStarted( - MapEventSource.doubleTapZoomAnimationController, - ); - _startListeningForAnimationInterruptions(); - } else if (status == AnimationStatus.completed) { - _stopListeningForAnimationInterruptions(); - - widget.controller.doubleTapZoomEnded( - MapEventSource.doubleTapZoomAnimationController, - ); - } - } - - void _handleDoubleTapZoomAnimation() { - widget.controller.moveRaw( - _doubleTapCenterAnimation.value, - _doubleTapZoomAnimation.value, - hasGesture: true, - source: MapEventSource.doubleTapZoomAnimationController, - ); - } - - void _handleOnTapUp(TapUpDetails details) { - _doubleTapHoldMaxDelay?.cancel(); - - if (++_tapUpCounter == 1) { - _doubleTapHoldMaxDelay = Timer(doubleTapDelay, _resetDoubleTapHold); - } - } - - void _handleDoubleTapHold(ScaleUpdateDetails details) { - _doubleTapHoldMaxDelay?.cancel(); - - final flags = _interactionOptions.flags; - if (InteractiveFlag.hasDoubleTapDragZoom(flags)) { - final verticalOffset = (_focalStartLocal - details.localFocalPoint).dy; - final newZoom = _mapZoomStart - verticalOffset / 360 * _camera.zoom; - - final min = _options.minZoom ?? 0.0; - final max = _options.maxZoom ?? double.infinity; - final actualZoom = math.max(min, math.min(max, newZoom)); - - widget.controller.moveRaw( - _camera.center, - actualZoom, - hasGesture: true, - source: MapEventSource.doubleTapHold, - ); - } - } - - void _handleFlingAnimation() { - if (!_flingAnimationStarted) { - _flingAnimationStarted = true; - widget.controller.flingStarted(MapEventSource.flingAnimationController); - _startListeningForAnimationInterruptions(); - } - - final newCenterPoint = _camera.project(_mapCenterStart) + - _flingAnimation.value.toPoint().rotate(_camera.rotationRad); - final newCenter = _camera.unproject(newCenterPoint); - - widget.controller.moveRaw( - newCenter, - _camera.zoom, - hasGesture: true, - source: MapEventSource.flingAnimationController, - ); - } - - void _resetDoubleTapHold() { - _doubleTapHoldMaxDelay?.cancel(); - _tapUpCounter = 0; - } - - void _flingAnimationStatusListener(AnimationStatus status) { - if (status == AnimationStatus.completed) { - _flingAnimationStarted = false; - _stopListeningForAnimationInterruptions(); - widget.controller.flingEnded(MapEventSource.flingAnimationController); - } - } - - /// - void _startListeningForAnimationInterruptions() { - _isListeningForInterruptions = true; - } - - void _stopListeningForAnimationInterruptions() { - _isListeningForInterruptions = false; - } - - /// Cancel every ongoing animated map movements. - void interruptAnimatedMovement(MapEvent event) { - if (_isListeningForInterruptions) { - _closeDoubleTapController(event.source); - _closeFlingAnimationController(event.source); - } - } - - double _getZoomForScale(double startZoom, double scale) { - final resultZoom = - scale == 1.0 ? startZoom : startZoom + math.log(scale) / math.ln2; - return _camera.clampZoom(resultZoom); - } - - Offset _rotateOffset(Offset offset) { - final radians = _camera.rotationRad; - if (radians != 0.0) { - final cos = math.cos(radians); - final sin = math.sin(radians); - final nx = (cos * offset.dx) + (sin * offset.dy); - final ny = (cos * offset.dy) - (sin * offset.dx); - - return Offset(nx, ny); - } - - return offset; - } -} diff --git a/lib/src/gestures/multi_finger_gesture.dart b/lib/src/gestures/multi_finger_gesture.dart deleted file mode 100644 index 93248f258..000000000 --- a/lib/src/gestures/multi_finger_gesture.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:meta/meta.dart'; - -// ignore_for_file: public_member_api_docs - -/// Use [MultiFingerGesture] to disable / enable certain gestures Use -/// [MultiFingerGesture.all] to enable all gestures, use -/// [MultiFingerGesture.none] to disable all gestures -/// -/// If you want mix gestures for example rotate and pinchZoom gestures then you -/// have two options A.) add you own flags: [MultiFingerGesture.rotate] | -/// [MultiFingerGesture.pinchZoom] B.) remove unnecessary flags from all: -/// [MultiFingerGesture.all] & ~[MultiFingerGesture.pinchMove] -@immutable -class MultiFingerGesture { - static const int all = pinchMove | pinchZoom | rotate; - static const int none = 0; - - /// enable move with two or more fingers - static const int pinchMove = 1 << 0; - - /// enable pinch zoom - static const int pinchZoom = 1 << 1; - - /// enable map rotate - static const int rotate = 1 << 2; - - /// Returns `true` if [leftFlags] has at least one member in [rightFlags] - /// (intersection) for example [leftFlags]= [MultiFingerGesture.pinchMove] | - /// [MultiFingerGesture.rotate] and [rightFlags]= [MultiFingerGesture.rotate] - /// returns true because both have [MultiFingerGesture.rotate] flag - static bool hasFlag(int leftFlags, int rightFlags) { - return leftFlags & rightFlags != 0; - } - - static bool hasPinchMove(int gestures) => hasFlag(gestures, pinchMove); - - static bool hasPinchZoom(int gestures) => hasFlag(gestures, pinchZoom); - - static bool hasRotate(int gestures) => hasFlag(gestures, rotate); -} diff --git a/lib/src/gestures/positioned_tap_detector_2.dart b/lib/src/gestures/positioned_tap_detector_2.dart deleted file mode 100644 index b043ac23c..000000000 --- a/lib/src/gestures/positioned_tap_detector_2.dart +++ /dev/null @@ -1,233 +0,0 @@ -// ////////////////////////////////////////////////////////////////// -// /// Based on the work by Ali Raghebi /// -// /// Now maintained here due to abandonment /// -// /// https://github.com/arsamme/flutter-positioned-tap-detector /// -// ////////////////////////////////////////////////////////////////// - -// ignore_for_file: public_member_api_docs - -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; - -typedef TapPositionCallback = void Function(TapPosition position); - -@immutable -class PositionedTapDetector2 extends StatefulWidget { - const PositionedTapDetector2({ - super.key, - this.child, - this.onTap, - this.onDoubleTap, - this.onSecondaryTap, - this.onLongPress, - Duration? doubleTapDelay, - this.behavior, - this.controller, - }) : doubleTapDelay = doubleTapDelay ?? _defaultDelay; - - static const _defaultDelay = Duration(milliseconds: 250); - static const _doubleTapMaxOffset = 48.0; - - final Widget? child; - final HitTestBehavior? behavior; - final TapPositionCallback? onTap; - final TapPositionCallback? onSecondaryTap; - final TapPositionCallback? onDoubleTap; - final TapPositionCallback? onLongPress; - final Duration doubleTapDelay; - final PositionedTapController? controller; - - @override - State createState() => _TapPositionDetectorState(); -} - -class _TapPositionDetectorState extends State { - final _controller = StreamController(); - - late final _stream = _controller.stream.asBroadcastStream(); - - Sink get _sink => _controller.sink; - late StreamSubscription _streamSub; - - PositionedTapController? _tapController; - TapDownDetails? _pendingTap; - TapDownDetails? _firstTap; - - @override - void initState() { - _updateController(); - _startStream(); - super.initState(); - } - - @override - void didUpdateWidget(PositionedTapDetector2 oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.controller != oldWidget.controller) { - _updateController(); - } - if (widget.doubleTapDelay != oldWidget.doubleTapDelay) { - _streamSub.cancel().then(_startStream); - } - } - - void _startStream([dynamic d]) { - _streamSub = _stream - .timeout(widget.doubleTapDelay) - .handleError(_onTimeout, test: (e) => e is TimeoutException) - .listen(_onTapConfirmed); - } - - void _updateController() { - _tapController?._state = null; - if (widget.controller != null) { - widget.controller!._state = this; - _tapController = widget.controller; - } - } - - void _onTimeout(dynamic error) { - final firstTap = _firstTap; - if (firstTap != null && _pendingTap == null) { - _postCallback(firstTap, widget.onTap); - } - } - - void _onTapConfirmed(TapDownDetails details) { - if (_firstTap == null) { - _firstTap = details; - } else { - _handleSecondTap(details); - } - } - - void _handleSecondTap(TapDownDetails secondTap) { - final firstTap = _firstTap; - - if (firstTap == null) return; - - if (_isDoubleTap(firstTap, secondTap)) { - _postCallback(secondTap, widget.onDoubleTap); - } else { - _postCallback(firstTap, widget.onTap); - _postCallback(secondTap, widget.onTap); - } - } - - bool _isDoubleTap(TapDownDetails d1, TapDownDetails d2) { - final dx = d1.globalPosition.dx - d2.globalPosition.dx; - final dy = d1.globalPosition.dy - d2.globalPosition.dy; - return sqrt(dx * dx + dy * dy) <= - PositionedTapDetector2._doubleTapMaxOffset; - } - - void _onTapDownEvent(TapDownDetails details) { - _pendingTap = details; - } - - void _onTapEvent() { - final pending = _pendingTap; - if (pending == null) return; - - if (widget.onDoubleTap == null) { - _postCallback(pending, widget.onTap); - } else { - _sink.add(pending); - } - - _pendingTap = null; - } - - void _onSecondaryTapEvent() { - final pending = _pendingTap; - if (pending == null) return; - - _postCallback(pending, widget.onSecondaryTap); - _pendingTap = null; - } - - void _onLongPressEvent() { - final pending = _pendingTap; - if (pending != null) { - if (_firstTap == null) { - _postCallback(pending, widget.onLongPress); - } else { - _sink.add(pending); - _pendingTap = null; - } - } - } - - Future _postCallback( - TapDownDetails details, - TapPositionCallback? callback, - ) async { - _firstTap = null; - if (callback != null) { - callback(TapPosition(details.globalPosition, details.localPosition)); - } - } - - @override - void dispose() { - _controller.close(); - _streamSub.cancel(); - _tapController?._state = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (widget.controller != null) { - if (widget.child != null) { - return widget.child!; - } else { - return Container(); - } - } - return GestureDetector( - behavior: widget.behavior ?? - (widget.child == null - ? HitTestBehavior.translucent - : HitTestBehavior.deferToChild), - onTap: _onTapEvent, - onLongPress: _onLongPressEvent, - onTapDown: _onTapDownEvent, - onSecondaryTapDown: _onTapDownEvent, - onSecondaryTap: _onSecondaryTapEvent, - child: widget.child, - ); - } -} - -class PositionedTapController { - _TapPositionDetectorState? _state; - - void onTap() => _state?._onTapEvent(); - - void onSecondaryTap() => _state?._onSecondaryTapEvent(); - - void onLongPress() => _state?._onLongPressEvent(); - - void onTapDown(TapDownDetails details) => _state?._onTapDownEvent(details); -} - -@immutable -class TapPosition { - const TapPosition(this.global, this.relative); - - final Offset global; - final Offset? relative; - - @override - bool operator ==(Object other) { - if (other is! TapPosition) return false; - final typedOther = other; - return global == typedOther.global && relative == other.relative; - } - - @override - int get hashCode => Object.hash(global, relative); -} diff --git a/lib/src/layer/tile_layer/tile_update_event.dart b/lib/src/layer/tile_layer/tile_update_event.dart index e21f8c913..2380494c7 100644 --- a/lib/src/layer/tile_layer/tile_update_event.dart +++ b/lib/src/layer/tile_layer/tile_update_event.dart @@ -78,8 +78,11 @@ class TileUpdateEvent { /// Checks if the [MapEvent] has been caused by a tap. bool wasTriggeredByTap() => mapEvent is MapEventTap || + mapEvent is MapEventLongPress || mapEvent is MapEventSecondaryTap || - mapEvent is MapEventLongPress; + mapEvent is MapEventSecondaryLongPress || + mapEvent is MapEventTertiaryTap || + mapEvent is MapEventTertiaryLongPress; @override String toString() => diff --git a/lib/src/layer/tile_layer/wms_tile_layer_options.dart b/lib/src/layer/tile_layer/wms_tile_layer_options.dart index b2bf075ad..65413bbe2 100644 --- a/lib/src/layer/tile_layer/wms_tile_layer_options.dart +++ b/lib/src/layer/tile_layer/wms_tile_layer_options.dart @@ -1,6 +1,6 @@ part of 'tile_layer.dart'; -/// Options for the [] +/// Options for the WMS [TileLayer]. @immutable class WMSTileLayerOptions { static const service = 'WMS'; diff --git a/lib/src/map/controller/events/map_event_source.dart b/lib/src/map/controller/events/map_event_source.dart new file mode 100644 index 000000000..ca6c209c2 --- /dev/null +++ b/lib/src/map/controller/events/map_event_source.dart @@ -0,0 +1,91 @@ +/// Event sources which are used to identify different types of +/// [MapEvent] events +enum MapEventSource { + /// The [MapEvent] is caused programmatically by the [MapController]. + mapController, + + /// The [MapEvent] is caused by a tap gesture. + /// (e.g. a click on the left mouse button or a tap on the touchscreen) + tap, + + /// The [MapEvent] is caused by a secondary tap gesture. + /// (e.g. a click on the right mouse button) + secondaryTap, + + /// The [MapEvent] is caused by a tertiary tap gesture + /// (e.g. click on the mouse scroll wheel). + tertiaryTap, + + /// The [MapEvent] is caused by a long press gesture. + longPress, + + /// The [MapEvent] is caused by a long press gesture on the secondary button + /// (e.g. the right mouse button). + secondaryLongPressed, + + /// The [MapEvent] is caused by a long press gesture on the tertiary button + /// (e.g. the mouse scroll wheel). + tertiaryLongPress, + + /// The [MapEvent] is caused by a double tap gesture. + doubleTap, + + /// The [MapEvent] is caused by a double tap and hold gesture. + doubleTapHold, + + /// The [MapEvent] is caused by the start of a drag gesture. + dragStart, + + /// The [MapEvent] is caused by a drag update gesture. + onDrag, + + /// The [MapEvent] is caused by the end of a drag gesture. + dragEnd, + + /// The [MapEvent] is caused by the start of a two finger gesture. + twoFingerStart, + + /// The [MapEvent] is caused by a two finger gesture update. + onTwoFinger, + + /// The [MapEvent] is caused by a the end of a two finger gesture. + twoFingerEnd, + + /// The [MapEvent] is caused by the [AnimationController] while performing + /// the fling gesture. + flingAnimationController, + + /// The [MapEvent] is caused by the [AnimationController] while performing + /// the double tap zoom in animation. + doubleTapZoomAnimationController, + + /// The [MapEvent] is caused by a change of the interactive flags. + interactiveFlagsChanged, + + /// The [MapEvent] is caused by calling fitCamera. + fitCamera, + + /// The [MapEvent] is caused by a custom source. + custom, + + /// The [MapEvent] is caused by a scroll wheel zoom gesture. + scrollWheel, + + /// The [MapEvent] is caused by a size change of the [FlutterMap] constraints. + nonRotatedSizeChange, + + /// The [MapEvent] is caused by the start of a key-press and drag gesture + /// (e.g. CTRL + drag to rotate the map). + keyTriggerDragRotateStart, + + /// The [MapEvent] is caused by a key-press and drag gesture + /// (e.g. CTRL + drag to rotate the map). + keyTriggerDragRotate, + + /// The [MapEvent] is caused by the end of a key-press and drag gesture + /// (e.g. CTRL + drag to rotate the map). + keyTriggerDragRotateEnd, + + /// The [MapEvent] is caused by the trackpad / touchpad of the device. + trackpad, +} diff --git a/lib/src/gestures/map_events.dart b/lib/src/map/controller/events/map_events.dart similarity index 80% rename from lib/src/gestures/map_events.dart rename to lib/src/map/controller/events/map_events.dart index 4d4a53fae..185fb4c09 100644 --- a/lib/src/gestures/map_events.dart +++ b/lib/src/map/controller/events/map_events.dart @@ -2,72 +2,6 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; -/// Event sources which are used to identify different types of -/// [MapEvent] events -enum MapEventSource { - /// The [MapEvent] is caused programmatically by the [MapController]. - mapController, - - /// The [MapEvent] is caused by a tap gesture. - tap, - - /// The [MapEvent] is caused by a secondary tap gesture. - secondaryTap, - - /// The [MapEvent] is caused by a long press gesture. - longPress, - - /// The [MapEvent] is caused by a double tap gesture. - doubleTap, - - /// The [MapEvent] is caused by a double tap and hold gesture. - doubleTapHold, - - /// The [MapEvent] is caused by the start of a drag gesture. - dragStart, - - /// The [MapEvent] is caused by a drag update gesture. - onDrag, - - /// The [MapEvent] is caused by the end of a drag gesture. - dragEnd, - - /// The [MapEvent] is caused by the start of a two finger gesture. - multiFingerGestureStart, - - /// The [MapEvent] is caused by a two finger gesture update. - onMultiFinger, - - /// The [MapEvent] is caused by a the end of a two finger gesture. - multiFingerEnd, - - /// The [MapEvent] is caused by the [AnimationController] while performing - /// the fling gesture. - flingAnimationController, - - /// The [MapEvent] is caused by the [AnimationController] while performing - /// the double tap zoom in animation. - doubleTapZoomAnimationController, - - /// The [MapEvent] is caused by a change of the interactive flags. - interactiveFlagsChanged, - - /// The [MapEvent] is caused by calling fitCamera. - fitCamera, - - /// The [MapEvent] is caused by a custom source. - custom, - - /// The [MapEvent] is caused by a scroll wheel zoom gesture. - scrollWheel, - - /// The [MapEvent] is caused by a size change of the [FlutterMap] constraints. - nonRotatedSizeChange, - - /// The [MapEvent] is caused by a CTRL + drag rotation gesture. - cursorKeyboardRotation, -} - /// Base event class which is emitted by MapController instance, the event /// is usually related to performed gesture on the map itself or it can /// be an event related to map configuration @@ -128,8 +62,14 @@ abstract class MapEventWithMove extends MapEvent { camera: camera, source: source, ), + MapEventSource.trackpad => MapEventTrackpadZoom( + oldCamera: oldCamera, + camera: camera, + source: source, + ), MapEventSource.onDrag || - MapEventSource.onMultiFinger || + MapEventSource.onTwoFinger || + MapEventSource.doubleTapHold || MapEventSource.mapController || MapEventSource.custom => MapEventMove( @@ -170,6 +110,18 @@ class MapEventSecondaryTap extends MapEvent { }); } +@immutable +class MapEventTertiaryTap extends MapEvent { + /// Point coordinates where user has tapped + final LatLng tapPosition; + + const MapEventTertiaryTap({ + required this.tapPosition, + required super.source, + required super.camera, + }); +} + /// Event which is fired when map is long-pressed @immutable class MapEventLongPress extends MapEvent { @@ -184,6 +136,33 @@ class MapEventLongPress extends MapEvent { }); } +/// Event which is fired when map is long-pressed with the secondary button. +@immutable +class MapEventSecondaryLongPress extends MapEvent { + /// Point coordinates where user has long-pressed + final LatLng tapPosition; + + const MapEventSecondaryLongPress({ + required this.tapPosition, + required super.source, + required super.camera, + }); +} + +/// Event which is fired when map is long-pressed with the tertiary button +/// (e.g. the mouse wheel is clicked). +@immutable +class MapEventTertiaryLongPress extends MapEvent { + /// Point coordinates where user has long-pressed + final LatLng tapPosition; + + const MapEventTertiaryLongPress({ + required this.tapPosition, + required super.source, + required super.camera, + }); +} + /// Event which is fired when map is being moved. @immutable class MapEventMove extends MapEventWithMove { @@ -285,6 +264,16 @@ class MapEventScrollWheelZoom extends MapEventWithMove { }); } +/// Event which is fired when the trackpad of a device is used to zoom +@immutable +class MapEventTrackpadZoom extends MapEventWithMove { + const MapEventTrackpadZoom({ + required super.source, + required super.oldCamera, + required super.camera, + }); +} + /// Event which is fired when animation for double tap gesture is started @immutable class MapEventDoubleTapZoomStart extends MapEvent { @@ -307,6 +296,24 @@ class MapEventDoubleTapZoomEnd extends MapEvent { }); } +/// Event which is fired when animation for double tap gesture is started +@immutable +class MapEventDoubleTapDragZoomStart extends MapEvent { + const MapEventDoubleTapDragZoomStart({ + required super.source, + required super.camera, + }); +} + +/// Event which is fired when animation for double tap gesture ends +@immutable +class MapEventDoubleTapDragZoomEnd extends MapEvent { + const MapEventDoubleTapDragZoomEnd({ + required super.source, + required super.camera, + }); +} + /// Event which is fired when map is being rotated @immutable class MapEventRotate extends MapEventWithMove { diff --git a/lib/src/map/controller/map_controller.dart b/lib/src/map/controller/map_controller.dart index 77d6a77da..486caf9ad 100644 --- a/lib/src/map/controller/map_controller.dart +++ b/lib/src/map/controller/map_controller.dart @@ -4,7 +4,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; -import 'package:flutter_map/src/misc/move_and_rotate_result.dart'; import 'package:latlong2/latlong.dart'; /// Controller to programmatically interact with [FlutterMap], such as @@ -103,9 +102,9 @@ abstract class MapController { /// The emitted [MapEventRotate.source]/[MapEventMove.source] properties will /// be [MapEventSource.mapController]. /// - /// The operation was successful if both fields of the resulting record are - /// `true`. - MoveAndRotateResult rotateAroundPoint( + /// Returns `true` unless the [degree] parameter patches the + /// current [MapCamera.rotation] value. + bool rotateAroundPoint( double degree, { Point? point, Offset? offset, @@ -117,11 +116,12 @@ abstract class MapController { /// /// Does not support offsets or rotations around custom points. /// - /// See documentation on those methods for more details. + /// This method calls the internal [MapControllerImpl.moveAndRotateRaw]. The + /// emitted events will have [MapEventSource.mapController] as event source. /// - /// The operation was successful if both fields of the resulting record are - /// `true`. - MoveAndRotateResult moveAndRotate( + /// Returns `true` unless [center], [zoom] and [degree] matched the current + /// value in [MapCamera]. + bool moveAndRotate( LatLng center, double zoom, double degree, { @@ -130,6 +130,9 @@ abstract class MapController { /// Move and zoom the map to fit [cameraFit]. /// + /// This method calls the internal [MapControllerImpl.fitCameraRaw]. The + /// emitted events will have [MapEventSource.mapController] as event source. + /// /// For information about the return value and emitted events, see [move]'s /// documentation. bool fitCamera(CameraFit cameraFit); diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index 5fc58ab5e..6f1bc874e 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -3,8 +3,7 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/gestures/map_interactive_viewer.dart'; -import 'package:flutter_map/src/misc/move_and_rotate_result.dart'; +import 'package:flutter_map/src/map/gestures/map_interactive_viewer.dart'; import 'package:latlong2/latlong.dart'; import 'package:vector_math/vector_math_64.dart'; @@ -23,10 +22,14 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> Animation? _rotationAnimation; Animation? _flingAnimation; late bool _animationHasGesture; + late MapEventSource _animationSource; + AnimationEndedCallback? _animatedEndedCallback; + AnimationCancelledCallback? _animatedCancelledCallback; late Offset _animationOffset; late Point _flingMapCenterStartPoint; - /// Constructor of the [MapController] implementation for internal usage. + /// Create a new [MapController] instance. This constructor is only used + /// internally. MapControllerImpl({MapOptions? options, TickerProvider? vsync}) : super( _MapControllerState( @@ -36,7 +39,9 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> vsync == null ? null : AnimationController(vsync: vsync), ), ) { - value.animationController?.addListener(_handleAnimation); + value.animationController + ?..addListener(_handleAnimation) + ..addStatusListener(_handleAnimationStatus); } /// Link the viewer state with the controller. This should be done once when @@ -51,15 +56,15 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> @override Stream get mapEventStream => _mapEventStreamController.stream; - /// Used to change [MapOptions] and update the required widgets. + /// Get the [MapOptions] from the controller state MapOptions get options { return value.options ?? (throw Exception('You need to have the FlutterMap widget rendered at ' 'least once before using the MapController.')); } - /// Get the current [MapCamera] instance. Prefer using - /// `MapCamera.of(context)` if possible. + /// Get the [MapCamera] from the controller state. + /// Prefer using `MapCamera.of(context)` if possible. @override MapCamera get camera { return value.camera ?? @@ -67,6 +72,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> 'least once before using the MapController.')); } + /// Get the [AnimationController] from the controller state AnimationController get _animationController { return value.animationController ?? (throw Exception('You need to have the FlutterMap widget rendered at ' @@ -80,6 +86,8 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> // ignore: library_private_types_in_public_api set value(_MapControllerState value) => super.value = value; + /// Implemented method from the public [MapController.move] API. + /// Calls [moveRaw] with [MapEventSource.mapController] as event source. @override bool move( LatLng center, @@ -96,6 +104,8 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> id: id, ); + /// Implemented method from the public [MapController.rotate] API. + /// Calls [rotateRaw] with [MapEventSource.mapController] as event source. @override bool rotate(double degree, {String? id}) => rotateRaw( degree, @@ -104,8 +114,11 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> id: id, ); + /// Implemented method from the public [MapController.rotateAroundPoint] API. + /// Calls [rotateAroundPointRaw] with [MapEventSource.mapController] as + /// event source. @override - MoveAndRotateResult rotateAroundPoint( + bool rotateAroundPoint( double degree, { Point? point, Offset? offset, @@ -120,8 +133,11 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> id: id, ); + /// Implemented method from the public [MapController.moveAndRotate] API. + /// Calls [moveAndRotateRaw] with [MapEventSource.mapController] as + /// event source. @override - MoveAndRotateResult moveAndRotate( + bool moveAndRotate( LatLng center, double zoom, double degree, { @@ -137,8 +153,13 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> id: id, ); + /// Implemented method from the public [MapController.fitCamera] API. + /// Calls [fitCameraRaw] with [MapEventSource.mapController] as event source. @override - bool fitCamera(CameraFit cameraFit) => fitCameraRaw(cameraFit); + bool fitCamera(CameraFit cameraFit) => fitCameraRaw( + cameraFit, + source: MapEventSource.mapController, + ); /// Internal endpoint to move the [MapCamera] and change zoom level. bool moveRaw( @@ -182,7 +203,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> source: source, id: id, ); - if (movementEvent != null) _emitMapEvent(movementEvent); + if (movementEvent != null) emitMapEvent(movementEvent); options.onPositionChanged?.call(newCamera, hasGesture); @@ -208,7 +229,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> // Update camera then emit events and callbacks value = value.withMapCamera(newCamera); - _emitMapEvent( + emitMapEvent( MapEventRotate( id: id, source: source, @@ -221,7 +242,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> /// Internal endpoint to rotate around a point that is not in the center of /// the map. - MoveAndRotateResult rotateAroundPointRaw( + bool rotateAroundPointRaw( double degree, { required Point? point, required Offset? offset, @@ -236,19 +257,14 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> throw ArgumentError('One of `point` or `offset` must be non-null'); } - if (degree == camera.rotation) { - return const (moveSuccess: false, rotateSuccess: false); - } + if (degree == camera.rotation) return false; if (offset == Offset.zero) { - return ( - moveSuccess: true, - rotateSuccess: rotateRaw( - degree, - hasGesture: hasGesture, - source: source, - id: id, - ), + return rotateRaw( + degree, + hasGesture: hasGesture, + source: source, + id: id, ); } @@ -259,30 +275,30 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> : Point(offset!.dx, offset.dy)) .rotate(camera.rotationRad); - return ( - moveSuccess: moveRaw( - camera.unproject( - rotationCenter + - (camera.project(camera.center) - rotationCenter) - .rotate(degrees2Radians * rotationDiff), - ), - camera.zoom, - hasGesture: hasGesture, - source: source, - id: id, - ), - rotateSuccess: rotateRaw( - camera.rotation + rotationDiff, - hasGesture: hasGesture, - source: source, - id: id, + final moved = moveRaw( + camera.unproject( + rotationCenter + + (camera.project(camera.center) - rotationCenter) + .rotate(degrees2Radians * rotationDiff), ), + camera.zoom, + hasGesture: hasGesture, + source: source, + id: id, + ); + final rotated = rotateRaw( + camera.rotation + rotationDiff, + hasGesture: hasGesture, + source: source, + id: id, ); + return moved || rotated; } /// Internal endpoint to move, rotate and change zoom level /// of the [MapCamera]. - MoveAndRotateResult moveAndRotateRaw( + /// Calls [moveRaw] and [rotateRaw]. + bool moveAndRotateRaw( LatLng newCenter, double newZoom, double newRotation, { @@ -290,36 +306,38 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> required bool hasGesture, required MapEventSource source, String? id, - }) => - ( - moveSuccess: moveRaw( - newCenter, - newZoom, - offset: offset, - hasGesture: hasGesture, - source: source, - id: id, - ), - rotateSuccess: rotateRaw( - newRotation, - id: id, - source: source, - hasGesture: hasGesture, - ), - ); + }) { + final moved = moveRaw( + newCenter, + newZoom, + offset: offset, + hasGesture: hasGesture, + source: source, + id: id, + ); + final rotated = rotateRaw( + newRotation, + id: id, + source: source, + hasGesture: hasGesture, + ); + return moved || rotated; + } - /// + /// Internal endpoint to fit the camera to a [CameraFit]. bool fitCameraRaw( CameraFit cameraFit, { Offset offset = Offset.zero, + bool hasGesture = false, + required MapEventSource source, }) { final fitted = cameraFit.fit(camera); return moveRaw( fitted.center, fitted.zoom, offset: offset, - hasGesture: false, - source: MapEventSource.mapController, + hasGesture: hasGesture, + source: source, ); } @@ -335,6 +353,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> return false; } + /// Update the [MapOptions] in the controller state. set options(MapOptions newOptions) { assert( newOptions != value.options, @@ -352,8 +371,8 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> if (value.options != null && value.options!.interactionOptions != newOptions.interactionOptions) { _interactiveViewerState.updateGestures( - value.options!.interactionOptions, - newOptions.interactionOptions, + value.options!.interactionOptions.gestures, + newOptions.interactionOptions.gestures, ); } @@ -364,183 +383,28 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> ); } + /// Update the [TickerProvider] for the animations in the controller state. set vsync(TickerProvider tickerProvider) { if (value.animationController == null) { value = _MapControllerState( options: value.options, camera: value.camera, animationController: AnimationController(vsync: tickerProvider) - ..addListener(_handleAnimation), + ..addListener(_handleAnimation) + ..addStatusListener(_handleAnimationStatus), ); } else { _animationController.resync(tickerProvider); } } - /// To be called when a gesture that causes movement starts. - void moveStarted(MapEventSource source) { - _emitMapEvent( - MapEventMoveStart( - camera: camera, - source: source, - ), - ); - } - - /// To be called when an ongoing drag movement updates. - void dragUpdated(MapEventSource source, Offset offset) { - final oldCenterPt = camera.project(camera.center); - - final newCenterPt = oldCenterPt + offset.toPoint(); - final newCenter = camera.unproject(newCenterPt); - - moveRaw( - newCenter, - camera.zoom, - hasGesture: true, - source: source, - ); - } - - /// To be called when a drag gesture ends. - void moveEnded(MapEventSource source) { - _emitMapEvent( - MapEventMoveEnd( - camera: camera, - source: source, - ), - ); - } - - /// To be called when a rotation gesture starts. - void rotateStarted(MapEventSource source) { - _emitMapEvent( - MapEventRotateStart( - camera: camera, - source: source, - ), - ); - } - - /// To be called when a rotation gesture ends. - void rotateEnded(MapEventSource source) { - _emitMapEvent( - MapEventRotateEnd( - camera: camera, - source: source, - ), - ); - } - - /// To be called when a fling gesture starts. - void flingStarted(MapEventSource source) { - _emitMapEvent( - MapEventFlingAnimationStart( - camera: camera, - source: MapEventSource.flingAnimationController, - ), - ); - } - - /// To be called when a fling gesture ends. - void flingEnded(MapEventSource source) { - _emitMapEvent( - MapEventFlingAnimationEnd( - camera: camera, - source: source, - ), - ); - } - - /// To be called when a fling gesture does not start. - void flingNotStarted(MapEventSource source) { - _emitMapEvent( - MapEventFlingAnimationNotStarted( - camera: camera, - source: source, - ), - ); - } - - /// To be called when a double tap zoom starts. - void doubleTapZoomStarted(MapEventSource source) { - _emitMapEvent( - MapEventDoubleTapZoomStart( - camera: camera, - source: source, - ), - ); - } - - /// To be called when a double tap zoom ends. - void doubleTapZoomEnded(MapEventSource source) { - _emitMapEvent( - MapEventDoubleTapZoomEnd( - camera: camera, - source: source, - ), - ); - } - - /// Called when a long-press gesture has happened, calls the - /// [MapOptions.onTap] callback and emits a [MapEventTap] event. - void tapped( - MapEventSource source, - TapPosition tapPosition, - LatLng position, - ) { - options.onTap?.call(tapPosition, position); - _emitMapEvent( - MapEventTap( - tapPosition: position, - camera: camera, - source: source, - ), - ); - } - - /// Called when a long-press gesture has happened, calls the - /// [MapOptions.onSecondaryTap] callback and emits a - /// [MapEventSecondaryTap] event. - void secondaryTapped( - MapEventSource source, - TapPosition tapPosition, - LatLng position, - ) { - options.onSecondaryTap?.call(tapPosition, position); - _emitMapEvent( - MapEventSecondaryTap( - tapPosition: position, - camera: camera, - source: source, - ), - ); - } - - /// Called when a long-press gesture has happened, calls the - /// [MapOptions.onLongPress] callback and emits a [MapEventLongPress] event. - void longPressed( - MapEventSource source, - TapPosition tapPosition, - LatLng position, - ) { - options.onLongPress?.call(tapPosition, position); - _emitMapEvent( - MapEventLongPress( - tapPosition: position, - camera: camera, - source: MapEventSource.longPress, - ), - ); - } - /// To be called when the map's size constraints change. void nonRotatedSizeChange( MapEventSource source, MapCamera oldCamera, MapCamera newCamera, ) { - _emitMapEvent( + emitMapEvent( MapEventNonRotatedSizeChange( source: MapEventSource.nonRotatedSizeChange, oldCamera: oldCamera, @@ -560,8 +424,11 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> required Curve curve, required bool hasGesture, required MapEventSource source, + AnimationEndedCallback? onAnimatedEnded, + AnimationCancelledCallback? onAnimationCancelled, }) { if (newRotation == camera.rotation) { + // if the rotation is the same we just need to move the MapCamera moveAnimatedRaw( newCenter, newZoom, @@ -569,12 +436,12 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> curve: curve, hasGesture: hasGesture, source: source, + onAnimatedEnded: onAnimatedEnded, + onAnimationCancelled: onAnimationCancelled, ); return; } - // cancel all ongoing animation - _animationController.stop(); - _resetAnimations(); + stopAnimationRaw(); if (newCenter == camera.center && newZoom == camera.zoom) return; @@ -592,6 +459,9 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> _animationController.duration = duration; _animationHasGesture = hasGesture; _animationOffset = offset; + _animationSource = source; + _animatedCancelledCallback = onAnimationCancelled; + _animatedEndedCallback = onAnimatedEnded; // start the animation from its start _animationController.forward(from: 0); @@ -606,11 +476,10 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> required Curve curve, required bool hasGesture, required MapEventSource source, + AnimationEndedCallback? onAnimatedEnded, + AnimationCancelledCallback? onAnimationCancelled, }) { - // cancel all ongoing animation - _animationController.stop(); - _resetAnimations(); - + stopAnimationRaw(); if (newRotation == camera.rotation) return; // create the new animation @@ -621,6 +490,9 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> _animationController.duration = duration; _animationHasGesture = hasGesture; _animationOffset = offset; + _animationSource = source; + _animatedCancelledCallback = onAnimationCancelled; + _animatedEndedCallback = onAnimatedEnded; // start the animation from its start _animationController.forward(from: 0); @@ -630,20 +502,22 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> /// This is commonly used by other gestures that should stop all /// ongoing movement. void stopAnimationRaw({bool canceled = true}) { - if (isAnimating) _animationController.stop(canceled: canceled); - } - - /// Getter that returns true if the [MapControllerImpl] performs a zoom, - /// drag or rotate animation. - bool get isAnimating => _animationController.isAnimating; - - void _resetAnimations() { + if (isAnimating) { + _animatedCancelledCallback?.call(camera, _animationSource); + _animationController.stop(canceled: canceled); + } _moveAnimation = null; _rotationAnimation = null; _zoomAnimation = null; _flingAnimation = null; + _animatedEndedCallback = null; + _animatedCancelledCallback = null; } + /// Getter that returns true if the [MapControllerImpl] performs a zoom, + /// drag or rotate animation. + bool get isAnimating => _animationController.isAnimating; + /// Fling animation for the map. /// The raw method allows to set all parameters. void flingAnimatedRaw({ @@ -656,13 +530,12 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> double ratio = 5, required bool hasGesture, }) { - // cancel all ongoing animation - _animationController.stop(); - _resetAnimations(); + stopAnimationRaw(); _animationHasGesture = hasGesture; _animationOffset = offset; _flingMapCenterStartPoint = camera.project(camera.center); + _animationSource = MapEventSource.flingAnimationController; final distance = (Offset.zero & Size(camera.nonRotatedSize.x, camera.nonRotatedSize.y)) @@ -694,11 +567,10 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> required Curve curve, required bool hasGesture, required MapEventSource source, + AnimationEndedCallback? onAnimatedEnded, + AnimationCancelledCallback? onAnimationCancelled, }) { - // cancel all ongoing animation - _animationController.stop(); - _resetAnimations(); - + stopAnimationRaw(); if (newCenter == camera.center && newZoom == camera.zoom) return; // create the new animation @@ -712,21 +584,22 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> _animationController.duration = duration; _animationHasGesture = hasGesture; _animationOffset = offset; + _animationSource = source; + _animatedCancelledCallback = onAnimationCancelled; + _animatedEndedCallback = onAnimatedEnded; // start the animation from its start _animationController.forward(from: 0); } - void _emitMapEvent(MapEvent event) { - if (event.source == MapEventSource.mapController && event is MapEventMove) { - _interactiveViewerState.interruptAnimatedMovement(event); - } - + /// Emit an [MapEvent] to the event system. + void emitMapEvent(MapEvent event) { options.onMapEvent?.call(event); - _mapEventSink.add(event); } + /// Callback that gets called by the [AnimationController] and updates + /// the [MapCamera]. void _handleAnimation() { // fling animation if (_flingAnimation != null) { @@ -736,7 +609,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> camera.unproject(newCenterPoint), camera.zoom, hasGesture: _animationHasGesture, - source: MapEventSource.flingAnimationController, + source: _animationSource, offset: _animationOffset, ); return; @@ -750,7 +623,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> _zoomAnimation?.value ?? camera.zoom, _rotationAnimation!.value, hasGesture: _animationHasGesture, - source: MapEventSource.mapController, + source: _animationSource, offset: _animationOffset, ); } else { @@ -758,7 +631,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> _moveAnimation!.value, _zoomAnimation?.value ?? camera.zoom, hasGesture: _animationHasGesture, - source: MapEventSource.mapController, + source: _animationSource, offset: _animationOffset, ); } @@ -770,7 +643,7 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> rotateRaw( _rotationAnimation!.value, hasGesture: _animationHasGesture, - source: MapEventSource.mapController, + source: _animationSource, ); } } @@ -781,8 +654,31 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> value.animationController?.dispose(); super.dispose(); } + + void _handleAnimationStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + final event = switch (_animationSource) { + MapEventSource.doubleTapZoomAnimationController => + MapEventDoubleTapZoomEnd( + camera: camera, + source: _animationSource, + ), + MapEventSource.flingAnimationController => MapEventFlingAnimationEnd( + camera: camera, + source: _animationSource, + ), + _ => MapEventMoveEnd( + camera: camera, + source: _animationSource, + ), + }; + emitMapEvent(event); + _animatedEndedCallback?.call(camera, _animationSource); + } + } } +/// The state for the [MapControllerImpl] [ValueNotifier]. @immutable class _MapControllerState { final MapCamera? camera; @@ -795,9 +691,19 @@ class _MapControllerState { required this.animationController, }); + /// Copy the [_MapControllerState] and set [MapCamera] to some new value. _MapControllerState withMapCamera(MapCamera camera) => _MapControllerState( options: options, camera: camera, animationController: animationController, ); } + +typedef AnimationEndedCallback = void Function( + MapCamera camera, + MapEventSource eventSource, +); +typedef AnimationCancelledCallback = void Function( + MapCamera camera, + MapEventSource eventSource, +); diff --git a/lib/src/gestures/latlng_tween.dart b/lib/src/map/gestures/latlng_tween.dart similarity index 100% rename from lib/src/gestures/latlng_tween.dart rename to lib/src/map/gestures/latlng_tween.dart diff --git a/lib/src/map/gestures/map_interactive_viewer.dart b/lib/src/map/gestures/map_interactive_viewer.dart new file mode 100644 index 000000000..40a170e0e --- /dev/null +++ b/lib/src/map/gestures/map_interactive_viewer.dart @@ -0,0 +1,315 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/map/gestures/services/base_services.dart'; + +/// The [MapInteractiveViewer] widget contains the [GestureDetector] and +/// [Listener] to handle gesture inputs. +/// It applies interactions (gestures/scroll/taps etc) to the +/// current [MapCamera] via the internal [controller]. +class MapInteractiveViewer extends StatefulWidget { + /// The builder callback for the child widget. + final ChildBuilder builder; + + /// Reference to the [MapControllerImpl]. + final MapControllerImpl controller; + + /// Create a new [MapInteractiveViewer] instance. + const MapInteractiveViewer({ + super.key, + required this.builder, + required this.controller, + }); + + @override + State createState() => MapInteractiveViewerState(); +} + +/// The state for the [MapInteractiveViewer] +class MapInteractiveViewerState extends State + with TickerProviderStateMixin { + TapGestureService? _tap; + LongPressGestureService? _longPress; + SecondaryTapGestureService? _secondaryTap; + SecondaryLongPressGestureService? _secondaryLongPress; + TertiaryTapGestureService? _tertiaryTap; + TertiaryLongPressGestureService? _tertiaryLongPress; + DoubleTapGestureService? _doubleTap; + ScrollWheelZoomGestureService? _scrollWheelZoom; + TwoFingerGesturesService? _twoFingerInput; + TrackpadZoomGestureService? _trackpadZoom; + TrackpadLegacyZoomGestureService? _trackpadLegacyZoom; + DragGestureService? _drag; + DoubleTapDragZoomGestureService? _doubleTapDragZoom; + KeyTriggerDragRotateGestureService? _keyTriggerDragRotate; + + MapControllerImpl get _controller => widget.controller; + + MapCamera get _camera => _controller.camera; + + MapOptions get _options => _controller.options; + + InteractionOptions get _interactionOptions => _options.interactionOptions; + + /// Initialize all services for the enabled gestures and input callbacks. + @override + void initState() { + super.initState(); + _controller.interactiveViewerState = this; + _controller.addListener(reload); + + // callback gestures for the application + if (_options.onTap != null) { + _tap = TapGestureService(controller: _controller); + } + if (_options.onLongPress != null) { + _longPress = LongPressGestureService(controller: _controller); + } + if (_options.onSecondaryTap != null) { + _secondaryTap = SecondaryTapGestureService(controller: _controller); + } + if (_options.onSecondaryLongPress != null) { + _secondaryLongPress = + SecondaryLongPressGestureService(controller: _controller); + } + if (_options.onTertiaryTap != null) { + _tertiaryTap = TertiaryTapGestureService(controller: _controller); + } + if (_options.onTertiaryLongPress != null) { + _tertiaryLongPress = + TertiaryLongPressGestureService(controller: _controller); + } + // gestures that change the map camera + updateGestures(null, _interactionOptions.gestures); + } + + /// Called when the widgets gets disposed, used to clean up Stream listeners + @override + void dispose() { + _controller.removeListener(reload); + super.dispose(); + } + + /// Calls [setState] on the [MapInteractiveViewer] widget to refresh the + /// widget. + void reload() { + if (mounted) setState(() {}); + } + + /// Widget build method + @override + Widget build(BuildContext context) { + final useDoubleTapCallback = + _doubleTap != null || _doubleTapDragZoom != null; + final useScaleCallback = _keyTriggerDragRotate != null || + _drag != null || + _doubleTapDragZoom != null || + _twoFingerInput != null; + + return Listener( + onPointerDown: (event) { + _controller.stopAnimationRaw(); + _options.onPointerDown?.call( + event, + _camera.offsetToCrs(event.localPosition), + ); + }, + onPointerHover: _options.onPointerHover == null + ? null + : (event) => _options.onPointerHover!.call( + event, + _camera.offsetToCrs(event.localPosition), + ), + onPointerCancel: _options.onPointerCancel == null + ? null + : (event) => _options.onPointerCancel!.call( + event, + _camera.offsetToCrs(event.localPosition), + ), + onPointerUp: _options.onPointerUp == null + ? null + : (event) => _options.onPointerUp!.call( + event, + _camera.offsetToCrs(event.localPosition), + ), + onPointerSignal: (event) { + switch (event) { + case final PointerScrollEvent event: + // mouse scroll wheel + // `stopAnimationRaw()` will probably get moved to the service + // to handle animated zooming with the scroll wheel but + // we keep it here for now. + _controller.stopAnimationRaw(); + _scrollWheelZoom?.submit(event); + break; + case final PointerScaleEvent event: + // Trackpad pinch gesture, in case the pointerPanZoom event + // callbacks can't be used and trackpad scrolling must still use + // this old PointerScrollSignal system. + // + // This is the case if not enough data is + // provided to the Flutter engine by platform APIs: + // - On **Windows**, where trackpad gesture support is dependent on + // the trackpad’s driver, + // - On **Web**, where not enough data is provided by browser APIs. + // + // https://docs.flutter.dev/release/breaking-changes/trackpad-gestures#description-of-change + _trackpadLegacyZoom?.submit(event); + break; + } + }, + // Trackpad gestures on most platforms since flutter 3.3 use + // these onPointerPanZoom* callbacks. + // See https://docs.flutter.dev/release/breaking-changes/trackpad-gestures + onPointerPanZoomStart: _trackpadZoom?.start, + onPointerPanZoomUpdate: _trackpadZoom?.update, + onPointerPanZoomEnd: _trackpadZoom?.end, + + child: GestureDetector( + onTapDown: _tap?.setDetails, + onTapCancel: _tap?.reset, + onTap: _tap?.submit, + + onLongPressStart: _longPress?.submit, + + onSecondaryTapDown: _secondaryTap?.setDetails, + onSecondaryTapCancel: _secondaryTap?.reset, + onSecondaryTap: _secondaryTap?.submit, + + onSecondaryLongPressStart: _secondaryLongPress?.submit, + + onDoubleTapDown: useDoubleTapCallback + ? (details) { + _doubleTapDragZoom?.isActive = true; + _doubleTap?.setDetails(details); + } + : null, + onDoubleTapCancel: useDoubleTapCallback + ? () { + _doubleTapDragZoom?.isActive = true; + _doubleTap?.reset(); + } + : null, + onDoubleTap: useDoubleTapCallback + ? () { + _doubleTapDragZoom?.isActive = false; + _doubleTap?.submit(); + } + : null, + + onTertiaryTapDown: _tertiaryTap?.setDetails, + onTertiaryTapCancel: _tertiaryTap?.reset, + onTertiaryTapUp: + _tertiaryTap == null ? null : (_) => _tertiaryTap?.submit(), + + onTertiaryLongPressStart: _tertiaryLongPress?.submit, + + // pan and scale, scale is a superset of the pan gesture + onScaleStart: useScaleCallback + ? (details) { + if (_keyTriggerDragRotate?.keyPressed ?? false) { + _keyTriggerDragRotate!.start(); + } else if (_doubleTapDragZoom?.isActive ?? false) { + _doubleTapDragZoom!.start(details); + } else if (details.pointerCount == 1) { + _drag?.start(details); + } else { + _twoFingerInput?.start(details); + } + } + : null, + onScaleUpdate: useScaleCallback + ? (details) { + if (_keyTriggerDragRotate?.keyPressed ?? false) { + _keyTriggerDragRotate!.update(details); + } else if (_doubleTapDragZoom?.isActive ?? false) { + _doubleTapDragZoom!.update(details); + } else if (details.pointerCount == 1 && details.scale == 1) { + _drag?.update(details); + } else { + _twoFingerInput?.update(details); + } + } + : null, + onScaleEnd: useScaleCallback + ? (details) { + if (_keyTriggerDragRotate?.keyPressed ?? false) { + _keyTriggerDragRotate!.end(); + } else if (_doubleTapDragZoom?.isActive ?? false) { + _doubleTapDragZoom!.isActive = false; + _doubleTapDragZoom!.end(details); + } else if (_drag?.isActive ?? false) { + _drag?.end(details); + } else { + _twoFingerInput?.end(details); + } + } + : null, + + child: widget.builder(context, _options, _camera), + ), + ); + } + + /// Perform all required actions when the [InteractionOptions] have changed. + /// Used by the internal map controller to update interaction gestures. + void updateGestures(MapGestures? oldGestures, MapGestures newGestures) { + if (oldGestures == newGestures) return; + if (newGestures.twoFingerMove || + newGestures.twoFingerZoom || + newGestures.twoFingerRotate) { + _twoFingerInput = TwoFingerGesturesService(controller: _controller); + } else { + _twoFingerInput = null; + } + + if (newGestures.drag) { + _drag = DragGestureService(controller: _controller); + } else { + _drag = null; + } + + if (newGestures.doubleTapZoomIn) { + _doubleTap = DoubleTapGestureService(controller: _controller); + } else { + _doubleTap = null; + } + + if (newGestures.scrollWheelZoom) { + _scrollWheelZoom = ScrollWheelZoomGestureService(controller: _controller); + } else { + _scrollWheelZoom = null; + } + + if (newGestures.trackpadZoom) { + _trackpadZoom = TrackpadZoomGestureService(controller: _controller); + _trackpadLegacyZoom = + TrackpadLegacyZoomGestureService(controller: _controller); + } else { + _trackpadZoom = null; + _trackpadLegacyZoom = null; + } + + if (newGestures.keyTriggerDragRotate) { + _keyTriggerDragRotate = + KeyTriggerDragRotateGestureService(controller: _controller); + } else { + _keyTriggerDragRotate = null; + } + + if (newGestures.doubleTapDragZoom) { + _doubleTapDragZoom = + DoubleTapDragZoomGestureService(controller: _controller); + } else { + _doubleTapDragZoom = null; + } + } +} + +/// Build method for the child widget. Provides [MapOptions] and [MapCamera] +/// as parameters. +typedef ChildBuilder = Widget Function( + BuildContext context, + MapOptions options, + MapCamera camera, +); diff --git a/lib/src/map/gestures/services/base_services.dart b/lib/src/map/gestures/services/base_services.dart new file mode 100644 index 000000000..f67669e59 --- /dev/null +++ b/lib/src/map/gestures/services/base_services.dart @@ -0,0 +1,78 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +part 'double_tap.dart'; +part 'double_tap_drag_zoom.dart'; +part 'drag.dart'; +part 'key_trigger_drag_rotate.dart'; +part 'long_press.dart'; +part 'scroll_wheel_zoom.dart'; +part 'tap.dart'; +part 'trackpad_legacy_zoom.dart'; +part 'trackpad_zoom.dart'; +part 'two_finger.dart'; + +/// Abstract base service class for every gesture service. +abstract class _BaseGestureService { + final MapControllerImpl controller; + + const _BaseGestureService({required this.controller}); + + /// Getter to provide a short way to access the [MapCamera]. + MapCamera get _camera => controller.camera; + + /// Getter to provide a short way to access the [MapOptions]. + MapOptions get _options => controller.options; +} + +/// Abstract base service for a gesture that fires only one time. +/// Commonly used by the different kind of tap gestures. +abstract class _SingleShotGestureService extends _BaseGestureService { + _SingleShotGestureService({required super.controller}); + + TapDownDetails? details; + + void setDetails(TapDownDetails newDetails) => details = newDetails; + + /// Called when the gesture fires and is confirmed. + void submit(); + + void reset() => details = null; +} + +/// Abstract base service for a long-press gesture that receives a +/// [LongPressStartDetails] when called. +abstract interface class _BaseLongPressGestureService { + /// Called when the gesture fires and is confirmed. + void submit(LongPressStartDetails details); +} + +/// Abstract base service for a gesture that fires multiple times time. +abstract interface class _ProgressableGestureService { + /// Called when the gesture is started, stores important values. + void start(ScaleStartDetails details); + + /// Called when the gesture receives an update, updates the [MapCamera]. + void update(ScaleUpdateDetails details); + + /// Called when the gesture ends, cleans up the previously stored values. + void end(ScaleEndDetails details); +} + +/// Return a rotated Offset +Offset _rotateOffset(MapCamera camera, Offset offset) { + final radians = camera.rotationRad; + if (radians == 0) return offset; + + final cos = math.cos(radians); + final sin = math.sin(radians); + final nx = (cos * offset.dx) + (sin * offset.dy); + final ny = (cos * offset.dy) - (sin * offset.dx); + + return Offset(nx, ny); +} diff --git a/lib/src/map/gestures/services/double_tap.dart b/lib/src/map/gestures/services/double_tap.dart new file mode 100644 index 000000000..3c6a448e6 --- /dev/null +++ b/lib/src/map/gestures/services/double_tap.dart @@ -0,0 +1,55 @@ +part of 'base_services.dart'; + +/// Service to handle double tap gestures to perform the +/// double-tap-zoom-in gesture. +class DoubleTapGestureService extends _SingleShotGestureService { + /// Create a new service for the double-tap gesture. + DoubleTapGestureService({required super.controller}); + + /// A double tap gesture tap has been registered + @override + void submit() { + if (details == null) return; + + // start double tap animation + final newZoom = _getZoomForScale(_camera.zoom, 2); + final newCenter = _camera.focusedZoomCenter( + details!.localPosition.toPoint(), + newZoom, + ); + + controller.emitMapEvent( + MapEventDoubleTapZoomStart( + camera: _camera, + source: MapEventSource.doubleTapZoomAnimationController, + ), + ); + + controller.moveAnimatedRaw( + newCenter, + newZoom, + hasGesture: true, + source: MapEventSource.doubleTapZoomAnimationController, + curve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 200), + ); + + controller.emitMapEvent( + MapEventDoubleTapZoomEnd( + camera: _camera, + source: MapEventSource.doubleTapZoomAnimationController, + ), + ); + + reset(); + } + + /// get the calculated zoom level for a given scaling, relative for the + /// startZoomLevel + double _getZoomForScale(double startZoom, double scale) { + if (scale == 1) { + return _camera.clampZoom(startZoom); + } + return _camera.clampZoom(startZoom + math.log(scale) / math.ln2); + } +} diff --git a/lib/src/map/gestures/services/double_tap_drag_zoom.dart b/lib/src/map/gestures/services/double_tap_drag_zoom.dart new file mode 100644 index 000000000..23d3e7a0a --- /dev/null +++ b/lib/src/map/gestures/services/double_tap_drag_zoom.dart @@ -0,0 +1,59 @@ +part of 'base_services.dart'; + +/// Service to handle the double-tap and drag gesture to let the user zoom in +/// and out with a single finger / one hand. +class DoubleTapDragZoomGestureService extends _BaseGestureService + implements _ProgressableGestureService { + /// Set to true if the [DoubleTapDragZoomGestureService] consumes the gesture + /// and prevents the normal double-tap logic from being executed. + bool isActive = false; + Offset? _focalLocalStart; + double? _mapZoomStart; + + /// Create a new service for the double-tap-drag-zoom gesture. + DoubleTapDragZoomGestureService({required super.controller}); + + /// Called when the gesture is started, stores important values. + @override + void start(ScaleStartDetails details) { + _focalLocalStart = details.localFocalPoint; + _mapZoomStart = _camera.zoom; + controller.emitMapEvent( + MapEventDoubleTapDragZoomStart( + camera: _camera, + source: MapEventSource.doubleTapHold, + ), + ); + } + + /// Called when the gesture receives an update, updates the [MapCamera]. + @override + void update(ScaleUpdateDetails details) { + if (_focalLocalStart == null || _mapZoomStart == null) return; + + final verticalOffset = (_focalLocalStart! - details.localFocalPoint).dy; + final newZoom = _mapZoomStart! - verticalOffset / 360 * _camera.zoom; + final min = _options.minZoom ?? 0.0; + final max = _options.maxZoom ?? double.infinity; + final actualZoom = math.max(min, math.min(max, newZoom)); + controller.moveRaw( + _camera.center, + actualZoom, + hasGesture: true, + source: MapEventSource.doubleTapHold, + ); + } + + /// Called when the gesture ends, cleans up the previously stored values. + @override + void end(ScaleEndDetails details) { + _mapZoomStart = null; + _focalLocalStart = null; + controller.emitMapEvent( + MapEventDoubleTapDragZoomEnd( + camera: _camera, + source: MapEventSource.doubleTapHold, + ), + ); + } +} diff --git a/lib/src/map/gestures/services/drag.dart b/lib/src/map/gestures/services/drag.dart new file mode 100644 index 000000000..2c0981085 --- /dev/null +++ b/lib/src/map/gestures/services/drag.dart @@ -0,0 +1,106 @@ +part of 'base_services.dart'; + +/// Service that handles drag gestures performed with one pointer +/// (like a finger or cursor). +class DragGestureService extends _BaseGestureService + implements _ProgressableGestureService { + Offset? _lastLocalFocal; + Offset? _focalStartLocal; + + bool get _flingEnabled => _options.interactionOptions.dragFlingAnimation; + + /// Create a new service to handle drag gestures. + DragGestureService({required super.controller}); + + /// Returns true if the screen currently gets dragged. + bool get isActive => _lastLocalFocal != null; + + /// Called when the gesture is started, stores important values. + @override + void start(ScaleStartDetails details) { + _lastLocalFocal = details.localFocalPoint; + _focalStartLocal = details.localFocalPoint; + controller.emitMapEvent( + MapEventMoveStart( + camera: _camera, + source: MapEventSource.dragStart, + ), + ); + } + + /// Called when the gesture receives an update, updates the [MapCamera]. + @override + void update(ScaleUpdateDetails details) { + if (_lastLocalFocal == null) return; + + final offset = _rotateOffset( + _camera, + _lastLocalFocal! - details.localFocalPoint, + ); + final oldCenterPt = _camera.project(_camera.center); + final newCenterPt = oldCenterPt + offset.toPoint(); + final newCenter = _camera.unproject(newCenterPt); + + controller.moveRaw( + newCenter, + _camera.zoom, + hasGesture: true, + source: MapEventSource.onDrag, + ); + + _lastLocalFocal = details.localFocalPoint; + } + + /// Called when the gesture ends, cleans up the previously stored values. + @override + void end(ScaleEndDetails details) { + controller.emitMapEvent( + MapEventMoveEnd( + camera: _camera, + source: MapEventSource.dragEnd, + ), + ); + final lastLocalFocal = _lastLocalFocal!; + final focalStartLocal = _focalStartLocal!; + _lastLocalFocal = null; + _focalStartLocal = null; + + flingAnimation(details, focalStartLocal, lastLocalFocal); + } + + void flingAnimation( + ScaleEndDetails details, + Offset focalStartLocal, + Offset lastLocalFocal, + ) { + if (!_flingEnabled) return; + + final magnitude = details.velocity.pixelsPerSecond.distance; + + // don't start fling if the magnitude is not high enough + if (magnitude < 800) { + controller.emitMapEvent( + MapEventFlingAnimationNotStarted( + source: MapEventSource.flingAnimationController, + camera: _camera, + ), + ); + return; + } + + final direction = details.velocity.pixelsPerSecond / magnitude; + + controller.flingAnimatedRaw( + velocity: magnitude / 1000.0, + direction: direction, + begin: focalStartLocal - lastLocalFocal, + hasGesture: true, + ); + controller.emitMapEvent( + MapEventFlingAnimationStart( + source: MapEventSource.flingAnimationController, + camera: _camera, + ), + ); + } +} diff --git a/lib/src/map/gestures/services/key_trigger_drag_rotate.dart b/lib/src/map/gestures/services/key_trigger_drag_rotate.dart new file mode 100644 index 000000000..821d239d7 --- /dev/null +++ b/lib/src/map/gestures/services/key_trigger_drag_rotate.dart @@ -0,0 +1,54 @@ +part of 'base_services.dart'; + +/// Service to handle the key-trigger and drag gesture to rotate the map. This +/// is by default a CTRL + drag. +/// +/// Can't extend from [_ProgressableGestureService] because of different +/// method signatures. +class KeyTriggerDragRotateGestureService extends _BaseGestureService { + /// Set to true if the gesture service is marked as active and consumes the + /// drag updates. + bool isActive = false; + + /// Getter for the keyboard keys that trigger the drag to rotate gesture. + List get keys => + _options.interactionOptions.keyTriggerDragRotateKeys; + + /// Create a new service that rotates the map if the map gets dragged while + /// a specified key is pressed. + KeyTriggerDragRotateGestureService({required super.controller}); + + /// Called when the gesture is started, stores important values. + void start() { + controller.emitMapEvent( + MapEventRotateStart( + camera: _camera, + source: MapEventSource.keyTriggerDragRotateStart, + ), + ); + } + + /// Called when the gesture receives an update, updates the [MapCamera]. + void update(ScaleUpdateDetails details) { + controller.rotateRaw( + _camera.rotation - (details.focalPointDelta.dy * 0.5), + hasGesture: true, + source: MapEventSource.keyTriggerDragRotate, + ); + } + + /// Called when the gesture ends, cleans up the previously stored values. + void end() { + controller.emitMapEvent( + MapEventRotateEnd( + camera: _camera, + source: MapEventSource.keyTriggerDragRotateEnd, + ), + ); + } + + /// Checks if one of the specified keys that enable this gesture is pressed. + bool get keyPressed => RawKeyboard.instance.keysPressed + .where((key) => keys.contains(key)) + .isNotEmpty; +} diff --git a/lib/src/map/gestures/services/long_press.dart b/lib/src/map/gestures/services/long_press.dart new file mode 100644 index 000000000..05b94865d --- /dev/null +++ b/lib/src/map/gestures/services/long_press.dart @@ -0,0 +1,73 @@ +part of 'base_services.dart'; + +/// Service to handle long press gestures for the +/// [MapOptions.onLongPress] callback. +class LongPressGestureService extends _BaseGestureService + implements _BaseLongPressGestureService { + /// Create a new service that handles long press gestures. + LongPressGestureService({required super.controller}); + + /// Called when a long press gesture with a primary button has been + /// recognized. A pointer has remained in contact with the screen at the + /// same location for a long period of time. + @override + void submit(LongPressStartDetails details) { + final position = _camera.offsetToCrs(details.localPosition); + _options.onLongPress?.call(details, position); + controller.emitMapEvent( + MapEventLongPress( + tapPosition: position, + camera: _camera, + source: MapEventSource.longPress, + ), + ); + } +} + +/// Service to handle secondary long press gestures for the +/// [MapOptions.onSecondaryLongPress] callback. +class SecondaryLongPressGestureService extends _BaseGestureService + implements _BaseLongPressGestureService { + /// Create a new service that handles long press gestures with the + /// secondary button. + SecondaryLongPressGestureService({required super.controller}); + + /// Called when a long press gesture with a primary button has been + /// recognized. A pointer has remained in contact with the screen at the + /// same location for a long period of time. + @override + void submit(LongPressStartDetails details) { + final position = _camera.offsetToCrs(details.localPosition); + _options.onSecondaryLongPress?.call(details, position); + controller.emitMapEvent( + MapEventSecondaryLongPress( + tapPosition: position, + camera: _camera, + source: MapEventSource.secondaryLongPressed, + ), + ); + } +} + +/// Service to handle tertiary long press gestures for the +/// [MapOptions.onTertiaryLongPress] callback. +class TertiaryLongPressGestureService extends _BaseGestureService + implements _BaseLongPressGestureService { + /// Creates a service that handles long press gestures by the tertiary button. + TertiaryLongPressGestureService({required super.controller}); + + /// A long press on the tertiary button has happen (e.g. click and hold on + /// the mouse scroll wheel) + @override + void submit(LongPressStartDetails details) { + final point = _camera.offsetToCrs(details.localPosition); + _options.onTertiaryLongPress?.call(details, point); + controller.emitMapEvent( + MapEventTertiaryLongPress( + tapPosition: point, + camera: _camera, + source: MapEventSource.tertiaryLongPress, + ), + ); + } +} diff --git a/lib/src/map/gestures/services/scroll_wheel_zoom.dart b/lib/src/map/gestures/services/scroll_wheel_zoom.dart new file mode 100644 index 000000000..365dce98f --- /dev/null +++ b/lib/src/map/gestures/services/scroll_wheel_zoom.dart @@ -0,0 +1,36 @@ +part of 'base_services.dart'; + +/// Service to handle the scroll wheel gesture to zoom the map in or out. +class ScrollWheelZoomGestureService extends _BaseGestureService { + /// Creates a service that handles scroll wheel zooming. + ScrollWheelZoomGestureService({required super.controller}); + + /// Shortcut for the zoom velocity of the scroll wheel + double get _scrollWheelVelocity => + _options.interactionOptions.scrollWheelVelocity; + + /// Handles mouse scroll events, called by the [Listener] of + /// the [MapInteractiveViewer]. + void submit(PointerScrollEvent details) { + if (details.scrollDelta.dy == 0) return; + + // Prevent scrolling of parent/child widgets simultaneously. + // See [PointerSignalResolver] documentation for more information. + GestureBinding.instance.pointerSignalResolver.register(details, (details) { + details as PointerScrollEvent; + final newZoom = + _camera.zoom - details.scrollDelta.dy * _scrollWheelVelocity; + // Calculate offset of mouse cursor from viewport center + final newCenter = _camera.focusedZoomCenter( + details.localPosition.toPoint(), + newZoom, + ); + controller.moveRaw( + newCenter, + newZoom, + hasGesture: true, + source: MapEventSource.scrollWheel, + ); + }); + } +} diff --git a/lib/src/map/gestures/services/tap.dart b/lib/src/map/gestures/services/tap.dart new file mode 100644 index 000000000..8a0d616d0 --- /dev/null +++ b/lib/src/map/gestures/services/tap.dart @@ -0,0 +1,77 @@ +part of 'base_services.dart'; + +/// Service to handle tap gestures for the [MapOptions.onTap] callback. +class TapGestureService extends _SingleShotGestureService { + /// Creates a service that handles short tap gestures with the primary button. + TapGestureService({required super.controller}); + + /// A tap with a primary button has occurred. + /// This triggers when the tap gesture wins. + @override + void submit() { + if (details == null) return; + + final point = _camera.offsetToCrs(details!.localPosition); + _options.onTap?.call(details!, point); + controller.emitMapEvent( + MapEventTap( + tapPosition: point, + camera: _camera, + source: MapEventSource.tap, + ), + ); + + reset(); + } +} + +/// Service to handle secondary tap gestures for the +/// [MapOptions.onSecondaryTap] callback. +class SecondaryTapGestureService extends _SingleShotGestureService { + /// Creates a service that handles short tap gestures by the secondary button. + SecondaryTapGestureService({required super.controller}); + + /// A tap with a secondary button has occurred. + /// This triggers when the tap gesture wins. + @override + void submit() { + if (details == null) return; + + final position = _camera.offsetToCrs(details!.localPosition); + _options.onSecondaryTap?.call(details!, position); + controller.emitMapEvent( + MapEventSecondaryTap( + tapPosition: position, + camera: _camera, + source: MapEventSource.secondaryTap, + ), + ); + + reset(); + } +} + +/// Service to handle tertiary tap gestures for the +/// [MapOptions.onTertiaryTap] callback. +class TertiaryTapGestureService extends _SingleShotGestureService { + /// Creates a service that handles short tap gestures by the tertiary button. + TertiaryTapGestureService({required super.controller}); + + /// A tertiary tap gesture has happen (e.g. click on the mouse scroll wheel) + @override + void submit() { + if (details == null) return; + + final point = _camera.offsetToCrs(details!.localPosition); + _options.onTertiaryTap?.call(details!, point); + controller.emitMapEvent( + MapEventTertiaryTap( + tapPosition: point, + camera: _camera, + source: MapEventSource.tertiaryTap, + ), + ); + + reset(); + } +} diff --git a/lib/src/map/gestures/services/trackpad_legacy_zoom.dart b/lib/src/map/gestures/services/trackpad_legacy_zoom.dart new file mode 100644 index 000000000..662c62cca --- /dev/null +++ b/lib/src/map/gestures/services/trackpad_legacy_zoom.dart @@ -0,0 +1,42 @@ +part of 'base_services.dart'; + +/// Service to handle the trackpad (aka. touchpad) zoom gesture to zoom +/// the map in or out. +/// +/// Trackpad pinch gesture, in case the pointerPanZoom event +/// callbacks can't be used and trackpad scrolling must still use +/// this old PointerScrollSignal system. +/// +/// This is the case if not enough data is +/// provided to the Flutter engine by platform APIs: +/// - On **Windows**, where trackpad gesture support is dependent on +/// the trackpad’s driver, +/// - On **Web**, where not enough data is provided by browser APIs. +/// +/// https://docs.flutter.dev/release/breaking-changes/trackpad-gestures#description-of-change +class TrackpadLegacyZoomGestureService extends _BaseGestureService { + static const _velocityAdjustment = 4.5; + + /// Creates a service that handles the legacy trackpad zoom. + TrackpadLegacyZoomGestureService({required super.controller}); + + double get _velocity => _options.interactionOptions.trackpadZoomVelocity; + + /// Called if a [PointerScaleEvent] gets submitted by the [Listener] widget. + void submit(PointerScaleEvent details) { + if (details.scale == 1) return; + + final tmpZoom = _camera.zoom + + (math.log(details.scale) / math.ln2) * _velocity * _velocityAdjustment; + final newZoom = _camera.clampZoom(tmpZoom); + + // TODO: calculate new center + + controller.moveRaw( + _camera.center, + newZoom, + hasGesture: true, + source: MapEventSource.trackpad, + ); + } +} diff --git a/lib/src/map/gestures/services/trackpad_zoom.dart b/lib/src/map/gestures/services/trackpad_zoom.dart new file mode 100644 index 000000000..74a9aee0f --- /dev/null +++ b/lib/src/map/gestures/services/trackpad_zoom.dart @@ -0,0 +1,73 @@ +part of 'base_services.dart'; + +/// Service to handle the trackpad (aka. touchpad) zoom gesture to zoom +/// the map in or out. +/// +/// Trackpad gestures on most platforms since flutter 3.3 use +/// these onPointerPanZoom* callbacks. +/// See https://docs.flutter.dev/release/breaking-changes/trackpad-gestures +class TrackpadZoomGestureService extends _BaseGestureService { + double _lastScale = 1; + Offset? _startLocalFocal; + LatLng? _startFocalLatLng; + Offset? _lastLocalFocal; + + /// Create a new service that handles trackpad zoom gestures. + TrackpadZoomGestureService({required super.controller}); + + double get _velocity => _options.interactionOptions.trackpadZoomVelocity; + + bool get _moveEnabled => _options.interactionOptions.gestures.drag; + + /// Callback method for [Listener.onPointerPanZoomStart]. + void start(PointerPanZoomStartEvent details) { + _lastScale = 1; + _startLocalFocal = details.localPosition; + _startFocalLatLng = _camera.offsetToCrs(_startLocalFocal!); + _lastLocalFocal = _startLocalFocal; + } + + /// Callback method for [Listener.onPointerPanZoomUpdate]. + void update(PointerPanZoomUpdateEvent details) { + if (_startFocalLatLng == null || + _startLocalFocal == null || + _lastLocalFocal == null) return; + if (details.scale == _lastScale) return; + final scaleFactor = (details.scale - _lastScale) * _velocity + 1; + + final tmpZoom = _camera.zoom * scaleFactor; + final newZoom = _camera.clampZoom(tmpZoom); + + LatLng newCenter = _camera.center; + if (_moveEnabled) { + math.Point newCenterPt; + + final oldCenterPt = _camera.project(_camera.center, newZoom); + final newFocalLatLng = _camera.offsetToCrs(_startLocalFocal!, newZoom); + final newFocalPt = _camera.project(newFocalLatLng, newZoom); + final oldFocalPt = _camera.project(_startFocalLatLng!, newZoom); + final zoomDifference = oldFocalPt - newFocalPt; + final moveDifference = + _rotateOffset(_camera, _startLocalFocal! - _lastLocalFocal!); + + newCenterPt = oldCenterPt + zoomDifference + moveDifference.toPoint(); + newCenter = _camera.unproject(newCenterPt, newZoom); + } + + _lastScale = details.scale; + _lastLocalFocal = details.localPosition; + controller.moveRaw( + newCenter, + newZoom, + hasGesture: true, + source: MapEventSource.trackpad, + ); + } + + /// Callback method for [Listener.onPointerPanZoomEnd]. + void end(PointerPanZoomEndEvent details) { + _startLocalFocal = null; + _startFocalLatLng = null; + _lastLocalFocal = null; + } +} diff --git a/lib/src/map/gestures/services/two_finger.dart b/lib/src/map/gestures/services/two_finger.dart new file mode 100644 index 000000000..305c0a2e4 --- /dev/null +++ b/lib/src/map/gestures/services/two_finger.dart @@ -0,0 +1,190 @@ +part of 'base_services.dart'; + +/// A gesture with multiple inputs. This service handles the following gestures: +/// - [MapGestures.twoFingerMove] +/// - [MapGestures.twoFingerZoom] +/// - [MapGestures.twoFingerRotate] +class TwoFingerGesturesService extends _BaseGestureService + implements _ProgressableGestureService { + MapCamera? _startCamera; + LatLng? _startFocalLatLng; + Offset? _startLocalFocal; + + MapCamera? _lastCamera; + Offset? _lastLocalFocal; + double? _lastScale; + double? _lastRotation; + + bool _zooming = false; + bool _moving = false; + bool _rotating = false; + + /// Getter as shortcut to check if [MapGestures.twoFingerMove] + /// is enabled. + bool get _moveEnabled => _options.interactionOptions.gestures.twoFingerMove; + + /// Getter as shortcut to check if [MapGestures.twoFingerRotate] + /// is enabled. + bool get _rotateEnabled => + _options.interactionOptions.gestures.twoFingerRotate; + + /// Getter as shortcut to check if [MapGestures.twoFingerZoom] + /// is enabled. + bool get _zoomEnabled => _options.interactionOptions.gestures.twoFingerZoom; + + double get _rotateThreshold => + _options.interactionOptions.twoFingerRotateThreshold; + + double get _moveThreshold => + _options.interactionOptions.twoFingerMoveThreshold; + + double get _zoomThreshold => + _options.interactionOptions.twoFingerZoomThreshold; + + /// Create a new two-finger gesture service that handels all multi-point + /// touchscreen gestures. + TwoFingerGesturesService({required super.controller}); + + /// Initialize gesture, called when gesture has started. + /// Stores all values, that are required later on. + @override + void start(ScaleStartDetails details) { + if (details.pointerCount < 2) return; + + _startCamera = _camera; + _startLocalFocal = _lastLocalFocal = details.localFocalPoint; + _startFocalLatLng = _camera.offsetToCrs(_startLocalFocal!); + + _lastScale = 1; + _lastRotation = 0; + _lastLocalFocal = _startLocalFocal; + _lastCamera = _startCamera; + + _rotating = false; + _moving = false; + _zooming = false; + + controller.emitMapEvent( + MapEventMoveStart( + camera: _camera, + source: MapEventSource.twoFingerStart, + ), + ); + } + + /// Called multiple times to handle updates to the gesture. + /// Updates the [MapCamera]. + @override + void update(ScaleUpdateDetails details) { + if (details.pointerCount < 2) return; + if (_lastLocalFocal == null || + _lastScale == null || + _lastRotation == null || + _startCamera == null || + _startLocalFocal == null || + _startFocalLatLng == null || + _lastCamera == null) { + return; + } + + double newRotation = _camera.rotation; + if (_rotateEnabled) { + // enable rotation if threshold is reached + if (!_rotating && details.rotation.abs() > _rotateThreshold) { + _rotating = true; + } + if (_rotating) { + newRotation -= (_lastRotation! - details.rotation) * 80; + } + } + + double newZoom = _camera.zoom; + if (_zoomEnabled) { + // enable zooming if threshold is reached + final scaleDiff = (_lastScale! - details.scale) * 1.5; + const startScale = 1; + if (!_zooming && (scaleDiff - startScale).abs() > _zoomThreshold) { + _zooming = true; + } + if (_zooming) { + final tmpZoom = details.scale == 1 + ? _startCamera!.zoom + : _startCamera!.zoom + math.log(details.scale) / math.ln2; + newZoom = _camera.clampZoom(tmpZoom); + } + } + + LatLng newCenter = _camera.center; + if (_moveEnabled) { + final distanceSqToStart = + (details.localFocalPoint - _startLocalFocal!).distanceSquared; + // Ignore twoFingerMoveThreshold if twoFingerZoomThreshold is reached. + if (!_moving && distanceSqToStart > _moveThreshold || _zooming) { + // Move threshold reached or zooming activated. + _moving = true; + } + if (_moving) { + math.Point newCenterPt; + if (_zooming) { + final oldCenterPt = _camera.project(_camera.center, newZoom); + final newFocalLatLong = + _camera.offsetToCrs(_startLocalFocal!, newZoom); + final newFocalPt = _camera.project(newFocalLatLong, newZoom); + final oldFocalPt = _camera.project(_startFocalLatLng!, newZoom); + final zoomDifference = oldFocalPt - newFocalPt; + final moveDifference = + _rotateOffset(_camera, _startLocalFocal! - _lastLocalFocal!); + + newCenterPt = oldCenterPt + zoomDifference + moveDifference.toPoint(); + } else { + // simplification for no zooming + final currentOffset = _rotateOffset( + _camera, + _lastLocalFocal! - details.localFocalPoint, + ); + newCenterPt = _camera.project(_camera.center, newZoom) + + currentOffset.toPoint(); + } + newCenter = _camera.unproject(newCenterPt, newZoom); + } + } + + controller.moveAndRotateRaw( + newCenter, + newZoom, + newRotation, + offset: Offset.zero, + hasGesture: true, + source: MapEventSource.onTwoFinger, + ); + + _lastRotation = details.rotation; + _lastCamera = _camera; + _lastScale = details.scale; + _lastLocalFocal = details.localFocalPoint; + } + + /// gesture has ended, clean up the previously stored values. + @override + void end(ScaleEndDetails details) { + if (details.pointerCount < 2) return; + _startCamera = null; + _startLocalFocal = null; + _startFocalLatLng = null; + + _lastCamera = null; + _lastScale = null; + _lastLocalFocal = null; + _lastRotation = null; + + _rotating = false; + _zooming = false; + _moving = false; + controller.emitMapEvent( + MapEventMoveEnd( + camera: _camera, + source: MapEventSource.twoFingerEnd, + ), + ); + } +} diff --git a/lib/src/map/options/cursor_keyboard_rotation.dart b/lib/src/map/options/cursor_keyboard_rotation.dart deleted file mode 100644 index 2fd9db7a4..000000000 --- a/lib/src/map/options/cursor_keyboard_rotation.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:meta/meta.dart'; - -/// See [CursorKeyboardRotationOptions.isKeyTrigger] -typedef IsKeyCursorRotationTrigger = bool Function(LogicalKeyboardKey key); - -/// The behaviour of the cursor/keyboard rotation function in terms of the angle -/// that the map is rotated to -/// -/// Does not disable cursor/keyboard rotation, or adjust its triggers: see -/// [CursorKeyboardRotationOptions.isKeyTrigger]. -/// -/// Also see [CursorKeyboardRotationOptions.setNorthOnClick]. -enum CursorRotationBehaviour { - /// Set the North of the map to the angle at which the user drags their cursor - setNorth, - - /// Offset the current rotation of the map to the angle at which the user drags - /// their cursor - offset, -} - -/// Options to configure cursor/keyboard rotation -/// -/// {@template cursorkeyboard_explanation} -/// Cursor/keyboard rotation is designed for desktop platforms, and allows the -/// cursor to be used to set the rotation of the map whilst a keyboard key is -/// held down (as triggered by [isKeyTrigger]). -/// {@endtemplate} -@immutable -class CursorKeyboardRotationOptions { - /// Whether to trigger cursor/keyboard rotation dependent on the currently - /// pressed [LogicalKeyboardKey] - /// - /// By default, rotation is triggered if any key in [defaultTriggerKeys] is - /// held (any of the "Control" keys). - /// - /// Fix to returning `false`, or use the - /// [CursorKeyboardRotationOptions.disabled] constructor to disable - /// cursor/keyboard rotation. - final IsKeyCursorRotationTrigger? isKeyTrigger; - - /// The behaviour of the cursor/keyboard rotation function in terms of the - /// angle that the map is rotated to - /// - /// Does not disable cursor/keyboard rotation, or adjust its triggers: see - /// [isKeyTrigger]. - /// - /// Defaults to [CursorRotationBehaviour.offset]. - final CursorRotationBehaviour behaviour; - - /// Whether to set the North of the map to the clicked angle, when the user - /// clicks their mouse without dragging (a `onPointerDown` event - /// followed by `onPointerUp` without a change in rotation) - final bool setNorthOnClick; - - /// Default trigger keys used in the default [isKeyTrigger] - static final defaultTriggerKeys = { - LogicalKeyboardKey.control, - LogicalKeyboardKey.controlLeft, - LogicalKeyboardKey.controlRight, - }; - - /// Create options to configure cursor/keyboard rotation - /// - /// {@macro cursorkeyboard_explanation} - /// - /// To disable cursor/keyboard rotation, fix [isKeyTrigger] to return `false`, - /// or use the [CursorKeyboardRotationOptions.disabled] constructor instead. - /// - /// This constructor defaults to setting [isKeyTrigger] to triggering if any - /// key in [defaultTriggerKeys] is held (any of the "Control" keys). - const CursorKeyboardRotationOptions({ - this.isKeyTrigger, - this.behaviour = CursorRotationBehaviour.offset, - this.setNorthOnClick = true, - }); - - /// Create options to disable cursor/keyboard rotation - /// - /// {@macro cursorkeyboard_explanation} - CursorKeyboardRotationOptions.disabled() : this(isKeyTrigger: (_) => false); -} diff --git a/lib/src/map/options/interaction.dart b/lib/src/map/options/interaction.dart deleted file mode 100644 index 8197993de..000000000 --- a/lib/src/map/options/interaction.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:flutter_map/flutter_map.dart'; -import 'package:meta/meta.dart'; - -/// All interactive options for [FlutterMap] -@immutable -final class InteractionOptions { - /// See [InteractiveFlag] for custom settings - final int flags; - - /// Prints multi finger gesture winner Helps to fine adjust - /// [rotationThreshold] and [pinchZoomThreshold] and [pinchMoveThreshold] - /// Note: only takes effect if [enableMultiFingerGestureRace] is true - final bool debugMultiFingerGestureWinner; - - /// If true then [rotationThreshold] and [pinchZoomThreshold] and - /// [pinchMoveThreshold] will race If multiple gestures win at the same time - /// then precedence: [pinchZoomWinGestures] > [rotationWinGestures] > - /// [pinchMoveWinGestures] - final bool enableMultiFingerGestureRace; - - /// Rotation threshold in degree default is 20.0 Map starts to rotate when - /// [rotationThreshold] has been achieved or another multi finger gesture wins - /// which allows [MultiFingerGesture.rotate] Note: if [interactiveFlags] - /// doesn't contain [InteractiveFlag.rotate] or [enableMultiFingerGestureRace] - /// is false then rotate cannot win - final double rotationThreshold; - - /// When [rotationThreshold] wins over [pinchZoomThreshold] and - /// [pinchMoveThreshold] then [rotationWinGestures] gestures will be used. By - /// default only [MultiFingerGesture.rotate] gesture will take effect see - /// [MultiFingerGesture] for custom settings - final int rotationWinGestures; - - /// Pinch Zoom threshold default is 0.5 Map starts to zoom when - /// [pinchZoomThreshold] has been achieved or another multi finger gesture - /// wins which allows [MultiFingerGesture.pinchZoom] Note: if - /// [interactiveFlags] doesn't contain [InteractiveFlag.pinchZoom] or - /// [enableMultiFingerGestureRace] is false then zoom cannot win - final double pinchZoomThreshold; - - /// When [pinchZoomThreshold] wins over [rotationThreshold] and - /// [pinchMoveThreshold] then [pinchZoomWinGestures] gestures will be used. By - /// default [MultiFingerGesture.pinchZoom] and [MultiFingerGesture.pinchMove] - /// gestures will take effect see [MultiFingerGesture] for custom settings - final int pinchZoomWinGestures; - - /// Pinch Move threshold default is 40.0 (note: this doesn't take any effect - /// on drag) Map starts to move when [pinchMoveThreshold] has been achieved or - /// another multi finger gesture wins which allows - /// [MultiFingerGesture.pinchMove] Note: if [interactiveFlags] doesn't contain - /// [InteractiveFlag.pinchMove] or [enableMultiFingerGestureRace] is false - /// then pinch move cannot win - final double pinchMoveThreshold; - - /// When [pinchMoveThreshold] wins over [rotationThreshold] and - /// [pinchZoomThreshold] then [pinchMoveWinGestures] gestures will be used. By - /// default [MultiFingerGesture.pinchMove] and [MultiFingerGesture.pinchZoom] - /// gestures will take effect see [MultiFingerGesture] for custom settings - final int pinchMoveWinGestures; - - /// The used velocity how fast the map should zoom in or out by scrolling - /// with the scroll wheel of a mouse. - final double scrollWheelVelocity; - - /// Options to configure cursor/keyboard rotation - /// - /// Cursor/keyboard rotation is designed for desktop platforms, and allows the - /// cursor to be used to set the rotation of the map whilst a keyboard key is - /// held down (as triggered by [CursorKeyboardRotationOptions.isKeyTrigger]). - /// - /// By default, rotation is triggered if any key in - /// [CursorKeyboardRotationOptions.defaultTriggerKeys] is held (any of the - /// "Control" keys). - /// - /// To disable cursor/keyboard rotation, use the - /// [CursorKeyboardRotationOptions.disabled] constructor. - final CursorKeyboardRotationOptions cursorKeyboardRotationOptions; - - /// Create a new [InteractionOptions] instance to be used - /// in [MapOptions.interactionOptions]. - const InteractionOptions({ - this.flags = InteractiveFlag.all, - this.debugMultiFingerGestureWinner = false, - this.enableMultiFingerGestureRace = false, - this.rotationThreshold = 20.0, - this.rotationWinGestures = MultiFingerGesture.rotate, - this.pinchZoomThreshold = 0.5, - this.pinchZoomWinGestures = - MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, - this.pinchMoveThreshold = 40.0, - this.pinchMoveWinGestures = - MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, - this.scrollWheelVelocity = 0.005, - this.cursorKeyboardRotationOptions = const CursorKeyboardRotationOptions(), - }) : assert( - rotationThreshold >= 0.0, - 'rotationThreshold needs to be a positive value', - ), - assert( - pinchZoomThreshold >= 0.0, - 'pinchZoomThreshold needs to be a positive value', - ), - assert( - pinchMoveThreshold >= 0.0, - 'pinchMoveThreshold needs to be a positive value', - ); - - @override - bool operator ==(Object other) => - other is InteractionOptions && - flags == other.flags && - debugMultiFingerGestureWinner == other.debugMultiFingerGestureWinner && - enableMultiFingerGestureRace == other.enableMultiFingerGestureRace && - rotationThreshold == other.rotationThreshold && - rotationWinGestures == other.rotationWinGestures && - pinchZoomThreshold == other.pinchZoomThreshold && - pinchZoomWinGestures == other.pinchZoomWinGestures && - pinchMoveThreshold == other.pinchMoveThreshold && - pinchMoveWinGestures == other.pinchMoveWinGestures && - scrollWheelVelocity == other.scrollWheelVelocity; - - @override - int get hashCode => Object.hash( - flags, - debugMultiFingerGestureWinner, - enableMultiFingerGestureRace, - rotationThreshold, - rotationWinGestures, - pinchZoomThreshold, - pinchZoomWinGestures, - pinchMoveThreshold, - pinchMoveWinGestures, - scrollWheelVelocity, - ); -} diff --git a/lib/src/map/options/interaction_options.dart b/lib/src/map/options/interaction_options.dart new file mode 100644 index 000000000..babef7c39 --- /dev/null +++ b/lib/src/map/options/interaction_options.dart @@ -0,0 +1,117 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_map/src/map/options/map_gestures.dart'; +import 'package:meta/meta.dart'; + +/// Set interaction options for input gestures. +/// Most commonly used is [InteractionOptions.gestures]. +@immutable +final class InteractionOptions { + /// Enable or disable specific gestures. By default all gestures are enabled. + /// If you want to disable all gestures or almost all gestures, use the + /// [MapGestures.none] constructor. + /// In case you want to disable only few gestures, use [MapGestures.all] + /// and you can use the [MapGestures.noRotation] for an easy way to + /// disable all rotation gestures. + /// + /// In addition you can specify your gestures via bitfield operations using + /// the [MapGestures.bitfield] constructor together with the static + /// fields in [InteractiveFlag]. + /// For more information see the documentation on [InteractiveFlag]. + final MapGestures gestures; + + /// Enable fling animation after panning if velocity is great enough. + /// + /// Defaults to true, this requires the `drag` gesture to be enabled. + final bool dragFlingAnimation; + + /// Map starts to rotate when [twoFingerRotateThreshold] has been achieved + /// or another multi finger gesture wins. + /// Default is 0.1 + final double twoFingerRotateThreshold; + + /// Map starts to zoom when [twoFingerZoomThreshold] has been achieved or + /// another multi finger gesture wins. + /// Default is 0.1 + final double twoFingerZoomThreshold; + + /// Map starts to move when [twoFingerMoveThreshold] has been achieved or + /// another multi finger gesture wins. This doesn't take any effect on drag + /// gestures by a single pointer like a single finger. + /// + /// This option gets superseded by [twoFingerZoomThreshold] if + /// [MapGestures.twoFingerMove] and [MapGestures.twoFingerZoom] are + /// both active and the [twoFingerZoomThreshold] is reached. + /// + /// Default is 3.0. + final double twoFingerMoveThreshold; + + /// The velocity how fast the map should zoom when using the scroll wheel + /// of the mouse. + /// Defaults to 0.01. + final double scrollWheelVelocity; + + /// The velocity how fast the map should zoom when using the + /// trackpad / touchpad. + /// Defaults to 0.5. + final double trackpadZoomVelocity; + + /// Override this option if you want to use custom keys for the key trigger + /// drag rotate gesture (aka CTRL+drag rotate gesture). + /// By default the left and right control key are both used. + final List keyTriggerDragRotateKeys; + + /// Default keys for the key press and drag to rotate gesture. + static const defaultKeyTriggerDragRotateKeys = [ + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + ]; + + /// Create a new [InteractionOptions] instance to be used + /// in [MapOptions.interactionOptions]. + const InteractionOptions({ + this.gestures = const MapGestures.all(), + this.twoFingerRotateThreshold = 0.1, + this.twoFingerZoomThreshold = 0.01, + this.twoFingerMoveThreshold = 3.0, + this.scrollWheelVelocity = 0.01, + this.trackpadZoomVelocity = 0.5, + this.dragFlingAnimation = true, + this.keyTriggerDragRotateKeys = defaultKeyTriggerDragRotateKeys, + }) : assert( + twoFingerRotateThreshold >= 0.0, + 'rotationThreshold needs to be a positive value', + ), + assert( + twoFingerZoomThreshold >= 0.0, + 'pinchZoomThreshold needs to be a positive value', + ), + assert( + twoFingerMoveThreshold >= 0.0, + 'pinchMoveThreshold needs to be a positive value', + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is InteractionOptions && + gestures == other.gestures && + twoFingerRotateThreshold == other.twoFingerRotateThreshold && + twoFingerZoomThreshold == other.twoFingerZoomThreshold && + twoFingerMoveThreshold == other.twoFingerMoveThreshold && + keyTriggerDragRotateKeys == other.keyTriggerDragRotateKeys && + scrollWheelVelocity == other.scrollWheelVelocity); + + @override + int get hashCode => Object.hash( + gestures, + twoFingerRotateThreshold, + twoFingerZoomThreshold, + twoFingerMoveThreshold, + keyTriggerDragRotateKeys, + scrollWheelVelocity, + ); +} diff --git a/lib/src/map/options/map_gestures.dart b/lib/src/map/options/map_gestures.dart new file mode 100644 index 000000000..1205b16a9 --- /dev/null +++ b/lib/src/map/options/map_gestures.dart @@ -0,0 +1,312 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:meta/meta.dart'; + +/// The available map gestures to move, zoom or rotate the map. +@immutable +class MapGestures { + /// Use this constructor if you want to set all gestures manually. + /// + /// Prefer to use the constructors [InteractiveFlags.all] or + /// [InteractiveFlags.none] to enable or disable all gestures by default. + /// + /// If you want to define your enabled gestures using bitfield operations, + /// use [InteractiveFlags.bitfield] instead. + const MapGestures({ + required this.drag, + required this.twoFingerMove, + required this.twoFingerZoom, + required this.doubleTapZoomIn, + required this.doubleTapDragZoom, + required this.scrollWheelZoom, + required this.twoFingerRotate, + required this.keyTriggerDragRotate, + required this.trackpadZoom, + }); + + /// This constructor enables all gestures by default. Use this constructor if + /// you want have all gestures enabled or disable some gestures only. + /// + /// In case you want have no or only few gestures enabled use the + /// [InteractiveFlags.none] constructor instead. + const MapGestures.all({ + this.drag = true, + this.twoFingerMove = true, + this.twoFingerZoom = true, + this.doubleTapZoomIn = true, + this.doubleTapDragZoom = true, + this.scrollWheelZoom = true, + this.twoFingerRotate = true, + this.keyTriggerDragRotate = true, + this.trackpadZoom = true, + }); + + /// This constructor has no enabled gestures by default. Use this constructor + /// if you want have no gestures enabled or only some specific gestures. + /// + /// In case you want have most or all of the gestures enabled use the + /// [InteractiveFlags.all] constructor instead. + const MapGestures.none({ + this.drag = false, + this.twoFingerMove = false, + this.twoFingerZoom = false, + this.doubleTapZoomIn = false, + this.doubleTapDragZoom = false, + this.scrollWheelZoom = false, + this.twoFingerRotate = false, + this.keyTriggerDragRotate = false, + this.trackpadZoom = false, + }); + + /// Enable or disable gestures by groups. + /// - [move] includes all gestures that alter [MapCamera.center]. + /// - [zoom] includes all gestures that alter [MapCamera.zoom]. + /// - [rotate] includes all gestures that alter [MapCamera.rotation]. + /// + /// - Use [MapGestures.allByGroup] to follow an blacklist approach. + /// - Use [MapGestures.noneByGroup] to follow an whitelist approach. + const MapGestures.byGroup({ + required bool move, + required bool zoom, + required bool rotate, + }) : this( + drag: move, + twoFingerMove: move, + doubleTapDragZoom: zoom, + doubleTapZoomIn: zoom, + scrollWheelZoom: zoom, + twoFingerZoom: zoom, + twoFingerRotate: rotate, + keyTriggerDragRotate: rotate, + trackpadZoom: zoom, + ); + + /// Enable gestures by groups. + /// - [move] includes all gestures that alter [MapCamera.center]. + /// - [zoom] includes all gestures that alter [MapCamera.zoom]. + /// - [rotate] includes all gestures that alter [MapCamera.rotation]. + /// + /// Every group is enabled by defaults when using this + /// constructor (blacklist approach). If you want to allow only certain + /// groups, use [MapGestures.noneByGroup] instead. + const MapGestures.allByGroup({ + bool move = true, + bool zoom = true, + bool rotate = true, + }) : this.byGroup(move: move, zoom: zoom, rotate: rotate); + + /// Disable gestures by groups. + /// - [move] includes all gestures that alter [MapCamera.center]. + /// - [zoom] includes all gestures that alter [MapCamera.zoom]. + /// - [rotate] includes all gestures that alter [MapCamera.rotation]. + /// + /// Every group is disabled by default when using this + /// constructor (whitelist approach). If you want to allow only certain + /// groups, use [MapGestures.allByGroup] instead. + const MapGestures.noneByGroup({ + bool move = false, + bool zoom = false, + bool rotate = false, + }) : this.byGroup(move: move, zoom: zoom, rotate: rotate); + + /// This constructor supports bitfield operations on the static fields + /// from [InteractiveFlag]. + factory MapGestures.bitfield(int flags) { + return MapGestures( + drag: InteractiveFlag.hasFlag(flags, InteractiveFlag.drag), + twoFingerMove: + InteractiveFlag.hasFlag(flags, InteractiveFlag.twoFingerMove), + twoFingerZoom: + InteractiveFlag.hasFlag(flags, InteractiveFlag.twoFingerZoom), + doubleTapZoomIn: + InteractiveFlag.hasFlag(flags, InteractiveFlag.doubleTapZoomIn), + doubleTapDragZoom: + InteractiveFlag.hasFlag(flags, InteractiveFlag.doubleTapDragZoom), + scrollWheelZoom: + InteractiveFlag.hasFlag(flags, InteractiveFlag.scrollWheelZoom), + twoFingerRotate: + InteractiveFlag.hasFlag(flags, InteractiveFlag.twoFingerRotate), + keyTriggerDragRotate: + InteractiveFlag.hasFlag(flags, InteractiveFlag.keyTriggerDragRotate), + trackpadZoom: + InteractiveFlag.hasFlag(flags, InteractiveFlag.trackpadZoom), + ); + } + + /// Enable panning with a single finger or cursor + final bool drag; + + /// Enable panning with multiple fingers + final bool twoFingerMove; + + /// Enable zooming with a multi-finger pinch gesture + final bool twoFingerZoom; + + /// Enable rotation with two-finger twist gesture + final bool twoFingerRotate; + + /// Enable zooming with a single-finger double tap gesture + final bool doubleTapZoomIn; + + /// Enable zooming with a single-finger double-tap-drag gesture + /// + /// The associated [MapEventSource] is [MapEventSource.doubleTapHold]. + final bool doubleTapDragZoom; + + /// Enable zooming with a mouse scroll wheel + final bool scrollWheelZoom; + + /// Enable zooming with the device trackpad / touchpad + final bool trackpadZoom; + + /// Enable rotation by pressing the defined keyboard key (by default CTRL key) + /// and dragging with the cursor + /// or finger. + final bool keyTriggerDragRotate; + + /// Wither to change the value of some gestures. Returns a new + /// [MapGestures] object. + MapGestures copyWith({ + bool? drag, + bool? flingAnimation, + bool? twoFingerZoom, + bool? twoFingerMove, + bool? doubleTapZoomIn, + bool? doubleTapDragZoom, + bool? scrollWheelZoom, + bool? twoFingerRotate, + bool? keyTriggerDragRotate, + bool? trackpadZoom, + }) => + MapGestures( + drag: drag ?? this.drag, + twoFingerZoom: twoFingerZoom ?? this.twoFingerZoom, + twoFingerMove: twoFingerMove ?? this.twoFingerMove, + doubleTapZoomIn: doubleTapZoomIn ?? this.doubleTapZoomIn, + doubleTapDragZoom: doubleTapDragZoom ?? this.doubleTapDragZoom, + scrollWheelZoom: scrollWheelZoom ?? this.scrollWheelZoom, + twoFingerRotate: twoFingerRotate ?? this.twoFingerRotate, + keyTriggerDragRotate: keyTriggerDragRotate ?? this.keyTriggerDragRotate, + trackpadZoom: trackpadZoom ?? this.trackpadZoom, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MapGestures && + runtimeType == other.runtimeType && + drag == other.drag && + twoFingerMove == other.twoFingerMove && + twoFingerZoom == other.twoFingerZoom && + doubleTapZoomIn == other.doubleTapZoomIn && + doubleTapDragZoom == other.doubleTapDragZoom && + scrollWheelZoom == other.scrollWheelZoom && + twoFingerRotate == other.twoFingerRotate && + keyTriggerDragRotate == other.keyTriggerDragRotate; + + @override + int get hashCode => Object.hash( + drag, + twoFingerMove, + twoFingerZoom, + doubleTapZoomIn, + doubleTapDragZoom, + scrollWheelZoom, + twoFingerRotate, + keyTriggerDragRotate, + ); +} + +/// Use [InteractiveFlag] to disable / enable certain events Use +/// [InteractiveFlag.all] to enable all events, use [InteractiveFlag.none] to +/// disable all events +/// +/// If you want mix interactions for example drag and rotate interactions then +/// you have two options: +/// a. Add your own flags: +/// [InteractiveFlag.drag] | [InteractiveFlag.twoFingerRotate] +/// b. Remove unnecessary flags from all: +/// [InteractiveFlag.all] & +/// ~[InteractiveFlag.flingAnimation] & +/// ~[InteractiveFlag.twoFingerMove] & +/// ~[InteractiveFlag.twoFingerZoom] & +/// ~[InteractiveFlag.doubleTapZoomIn] +abstract class InteractiveFlag { + const InteractiveFlag._(); + + /// All available interactive flags, use as `flags: InteractiveFlag.all` to + /// enable all gestures. + static const int all = drag | + twoFingerMove | + twoFingerZoom | + doubleTapZoomIn | + doubleTapDragZoom | + scrollWheelZoom | + twoFingerRotate | + trackpadZoom | + keyTriggerDragRotate; + + /// No enabled interactive flags, use as `flags: InteractiveFlag.none` to + /// have a non interactive map. + static const int none = 0; + + /// Enable panning with a single finger or cursor + static const int drag = 1 << 0; + + /// Enable panning with multiple fingers + static const int twoFingerMove = 1 << 2; + + /// Enable panning with multiple fingers + @Deprecated('Renamed to twoFingerMove') + static const int pinchMove = twoFingerMove; + + /// Enable zooming with a multi-finger pinch gesture + static const int twoFingerZoom = 1 << 3; + + /// Enable zooming with a multi-finger pinch gesture + @Deprecated('Renamed to twoFingerZoom') + static const int pinchZoom = twoFingerZoom; + + /// Enable zooming with a single-finger double tap gesture + static const int doubleTapZoomIn = 1 << 4; + + /// Enable zooming with a single-finger double tap gesture + @Deprecated('Renamed to doubleTapZoomIn') + static const int doubleTapZoom = doubleTapZoomIn; + + /// Enable zooming with a single-finger double-tap-drag gesture + /// + /// The associated [MapEventSource] is [MapEventSource.doubleTapHold]. + static const int doubleTapDragZoom = 1 << 5; + + /// Enable zooming with a mouse scroll wheel + static const int scrollWheelZoom = 1 << 6; + + /// Enable rotation with two-finger twist gesture + /// + /// For controlling cursor/keyboard rotation, see + /// [InteractionOptions.cursorKeyboardRotationOptions]. + static const int twoFingerRotate = 1 << 7; + + /// Enable rotation with two-finger twist gesture. + @Deprecated('Renamed to twoFingerRotate') + static const int rotate = twoFingerRotate; + + /// Enable rotation by pressing the defined keyboard keys + /// (by default CTRL Key) and drag the map with the cursor. + /// To change the key see [InteractionOptions.cursorKeyboardRotationOptions]. + static const int keyTriggerDragRotate = 1 << 8; + + /// Enable zooming by using the trackpad / touchpad of a device. + static const int trackpadZoom = 1 << 9; + + /// Returns `true` if [leftFlags] has at least one member in [rightFlags] + /// (intersection) for example + /// [leftFlags] = [InteractiveFlag.drag] | [InteractiveFlag.twoFingerRotate] + /// and + /// [rightFlags] = [InteractiveFlag.twoFingerRotate] + /// | [InteractiveFlag.flingAnimation] + /// returns true because both have the [InteractiveFlag.twoFingerRotate] flag. + static bool hasFlag(int leftFlags, int rightFlags) { + return leftFlags & rightFlags != 0; + } +} diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/map_options.dart similarity index 71% rename from lib/src/map/options/options.dart rename to lib/src/map/options/map_options.dart index 302dfd00d..6f7a8c230 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/map_options.dart @@ -7,41 +7,8 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; import 'package:latlong2/latlong.dart'; -/// Callback to notify when the map emits a [MapEvent]. -typedef MapEventCallback = void Function(MapEvent); - -/// Callback to notify when the map registers a confirmed short tap gesture. -typedef TapCallback = void Function(TapPosition tapPosition, LatLng point); - -/// Callback to notify when the map emits long-press gesture -typedef LongPressCallback = void Function( - TapPosition tapPosition, - LatLng point, -); - -/// Callback to notify when the map registers a pointer down event. -typedef PointerDownCallback = void Function( - PointerDownEvent event, - LatLng point, -); - -/// Callback to notify when the map registers a pointer up event. -typedef PointerUpCallback = void Function(PointerUpEvent event, LatLng point); - -/// Callback to notify when the map registers a pointer cancel event. -typedef PointerCancelCallback = void Function( - PointerCancelEvent event, - LatLng point, -); - -/// Callback to notify when the map registers a pointer hover event. -typedef PointerHoverCallback = void Function( - PointerHoverEvent event, - LatLng point, -); -typedef PositionCallback = void Function(MapCamera camera, bool hasGesture); - -/// All options for the [FlutterMap] widget. +/// The map options are used to configure the map settings. +/// It gets provided to the map widget as the [FlutterMap.options] parameter. @immutable class MapOptions { /// The Coordinate Reference System, defaults to [Epsg3857]. @@ -74,44 +41,64 @@ class MapOptions { /// yellow grey-ish color. final Color backgroundColor; - /// Callback that fires when the map gets tapped or clicked with the - /// primary mouse button. This is normally the left mouse button. This - /// callback does not fire if the gesture is recognized as a double click. + /// Callback that gets called when the user has performed a confirmed single + /// tap or click on the map. If double tap gestures are enabled in + /// [InteractionOptions.gestures], the callback waits until the + /// double-tap delay has passed by and the tap gesture is confirmed. final TapCallback? onTap; - /// Callback that fires when the map gets tapped or clicked with the - /// secondary mouse button. This is normally the right mouse button. - final TapCallback? onSecondaryTap; - - /// Callback that fires when the primary pointer has remained in contact with the - /// screen at the same location for a long period of time. + /// Callback that gets called when the user has performed a confirmed + /// long press on the map. final LongPressCallback? onLongPress; - /// A pointer that might cause a tap has contacted the screen at a - /// particular location. - final PointerDownCallback? onPointerDown; - - /// A pointer that triggers a tap has stopped contacting the screen at a - /// particular location. - final PointerUpCallback? onPointerUp; - - /// This callback fires when the pointer that previously triggered the - /// onTapDown won’t end up causing a tap. - final PointerCancelCallback? onPointerCancel; - - /// Called when a pointer that has not triggered an onPointerDown - /// changes position. - /// This is only fired for pointers which report their location when not - /// down (e.g. mouse pointers, but not most touch pointers) - final PointerHoverCallback? onPointerHover; - - /// This callback fires when the [MapCamera] data has changed. This gets - /// called if the zoom level, or map center changes. - final PositionCallback? onPositionChanged; + /// Callback that gets called when the user has performed a confirmed + /// single secondary tap or click on the map. This is for example when the + /// user clicks with the right mouse button. + final TapCallback? onSecondaryTap; - /// This callback fires on every map event that gets emitted. Check the type - /// of [MapEvent] to distinguish between the different event types. - final MapEventCallback? onMapEvent; + /// Callback that gets called when the user has performed a confirmed + /// long press on the map with the secondary pointer. + final LongPressCallback? onSecondaryLongPress; + + /// Callback that gets called when the user has performed a confirmed + /// tap or click with the tertiary pointer. This is for example by clicking + /// on the scroll wheel of the mouse. + final TapCallback? onTertiaryTap; + + /// Callback that gets called when the user has performed a confirmed + /// long press using the tertiary pointer. This is for example by + /// long pressing the scroll wheel of the mouse. + final LongPressCallback? onTertiaryLongPress; + + /// Callback that gets called when internal + /// [Listener.onPointerDown] callback fires. Useful for custom or advanced + /// gesture handling. + final void Function(PointerDownEvent event, LatLng point)? onPointerDown; + + /// Callback that gets called when internal + /// [Listener.onPointerUp] callback fires. Useful for custom or advanced + /// gesture handling. + final void Function(PointerUpEvent event, LatLng point)? onPointerUp; + + /// Callback that gets called when internal + /// [Listener.onPointerCancel] callback fires. Useful for custom or advanced + /// gesture handling. + final void Function(PointerCancelEvent event, LatLng point)? onPointerCancel; + + /// Callback that gets called when internal + /// [Listener.onPointerHover] callback fires. Useful for custom or advanced + /// gesture handling. + final void Function(PointerHoverEvent event, LatLng point)? onPointerHover; + + /// Callback that gets called when the [MapCamera] changes position. + final void Function(MapCamera camera, bool hasGesture)? onPositionChanged; + + /// Callback to listen for events emitted by the FlutterMap event system. + /// Every event is a subclass of [MapEvent]. Check its type to filter + /// for a specific event. + /// + /// Events for gestures are only emitted if the respective gesture is enabled. + final void Function(MapEvent event)? onMapEvent; /// Define limits for viewing the map. final CameraConstraint cameraConstraint; @@ -121,13 +108,13 @@ class MapOptions { /// Only use this if your map isn't built immediately (like inside FutureBuilder) /// and you need to access the controller as soon as the map is built. /// Otherwise you can use WidgetsBinding.instance.addPostFrameCallback - /// In initState to controll the map before the next frame. + /// In initState to control the map before the next frame. final VoidCallback? onMapReady; /// Flag to enable the built in keep alive functionality /// /// If the map is within a complex layout, such as a [ListView] or [PageView], - /// the map will reset to it's inital position after it appears back into view. + /// the map will reset to it's initial position after it appears back into view. /// To ensure this doesn't happen, enable this flag to prevent the [FlutterMap] /// widget from rebuilding. final bool keepAlive; @@ -174,7 +161,8 @@ class MapOptions { /// Gesture and input options for the map widget. final InteractionOptions interactionOptions; - /// Create the map options for [FlutterMap]. + /// Create the map options for [FlutterMap]. Set custom options or override + /// default values. const MapOptions({ this.crs = const Epsg3857(), this.initialCenter = const LatLng(50.5, 30.51), @@ -187,8 +175,11 @@ class MapOptions { this.maxZoom, this.backgroundColor = const Color(0xFFE0E0E0), this.onTap, - this.onSecondaryTap, this.onLongPress, + this.onSecondaryTap, + this.onSecondaryLongPress, + this.onTertiaryTap, + this.onTertiaryLongPress, this.onPointerDown, this.onPointerUp, this.onPointerCancel, @@ -273,3 +264,12 @@ class MapOptions { applyPointerTranslucencyToLayers, ]); } + +/// Callback function signature used by short taps +typedef TapCallback = void Function(TapDownDetails details, LatLng point); + +/// Callback function signature used by long presses +typedef LongPressCallback = void Function( + LongPressStartDetails details, + LatLng point, +); diff --git a/lib/src/map/widget.dart b/lib/src/map/widget.dart index a17e58e4a..8b3ad9558 100644 --- a/lib/src/map/widget.dart +++ b/lib/src/map/widget.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/gestures/map_interactive_viewer.dart'; +import 'package:flutter_map/src/map/gestures/map_interactive_viewer.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; import 'package:logger/logger.dart'; @@ -138,7 +138,10 @@ class _FlutterMapStateContainer extends State _parentConstraintsAreSet(context, constraints)) { _initialCameraFitApplied = true; - _mapController.fitCamera(widget.options.initialCameraFit!); + _mapController.fitCameraRaw( + widget.options.initialCameraFit!, + source: MapEventSource.fitCamera, + ); } } diff --git a/lib/src/misc/center_zoom.dart b/lib/src/misc/center_zoom.dart deleted file mode 100644 index cc563e558..000000000 --- a/lib/src/misc/center_zoom.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:latlong2/latlong.dart'; -import 'package:meta/meta.dart'; - -/// Geographical point with applied zoom level -@immutable -class CenterZoom { - /// Coordinates for zoomed point - final LatLng center; - - /// Zoom value - final double zoom; - - /// Create a new [CenterZoom] object by setting all its values. - const CenterZoom({required this.center, required this.zoom}); - - /// Wither that returns a new [CenterZoom] object with an updated map center. - CenterZoom withCenter(LatLng center) => - CenterZoom(center: center, zoom: zoom); - - /// Wither that returns a new [CenterZoom] object with an updated zoom value. - CenterZoom withZoom(double zoom) => CenterZoom(center: center, zoom: zoom); - - @override - int get hashCode => Object.hash(center, zoom); - - @override - bool operator ==(Object other) => - other is CenterZoom && other.center == center && other.zoom == zoom; - - @override - String toString() => 'CenterZoom(center: $center, zoom: $zoom)'; -} diff --git a/lib/src/misc/move_and_rotate_result.dart b/lib/src/misc/move_and_rotate_result.dart deleted file mode 100644 index 3b1ea59ac..000000000 --- a/lib/src/misc/move_and_rotate_result.dart +++ /dev/null @@ -1,2 +0,0 @@ -/// The result of the `moveAndRotate` [MapController] endpoint. -typedef MoveAndRotateResult = ({bool moveSuccess, bool rotateSuccess}); diff --git a/lib/src/misc/position.dart b/lib/src/misc/position.dart deleted file mode 100644 index 116b57b97..000000000 --- a/lib/src/misc/position.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter_map/src/geo/latlng_bounds.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:meta/meta.dart'; - -// ignore_for_file: public_member_api_docs - -@immutable -class MapPosition { - final LatLng? center; - final LatLngBounds? bounds; - final double? zoom; - final bool hasGesture; - - const MapPosition({ - this.center, - this.bounds, - this.zoom, - this.hasGesture = false, - }); - - @override - int get hashCode => Object.hash(center, bounds, zoom); - - @override - bool operator ==(Object other) => - other is MapPosition && - other.center == center && - other.bounds == bounds && - other.zoom == zoom; -} - -typedef PositionCallback = void Function(MapPosition position, bool hasGesture); diff --git a/test/flutter_map_test.dart b/test/flutter_map_test.dart index 6d9599380..cb20bdf6f 100644 --- a/test/flutter_map_test.dart +++ b/test/flutter_map_test.dart @@ -215,7 +215,12 @@ class TestRebuildsApp extends StatefulWidget { class _TestRebuildsAppState extends State { MapController _mapController = MapController(); Crs _crs = const Epsg3857(); - int _interactiveFlags = InteractiveFlag.all; + + /// double tap gestures delay the tap gestures, disable them here + MapGestures _interactiveFlags = const MapGestures.all( + doubleTapZoomIn: false, + doubleTapDragZoom: false, + ); @override void dispose() { @@ -232,7 +237,7 @@ class _TestRebuildsAppState extends State { options: MapOptions( crs: _crs, interactionOptions: InteractionOptions( - flags: _interactiveFlags, + gestures: _interactiveFlags, ), ), children: [ @@ -242,10 +247,9 @@ class _TestRebuildsAppState extends State { TextButton( onPressed: () { setState(() { - _interactiveFlags = - InteractiveFlag.hasDrag(_interactiveFlags) - ? _interactiveFlags & ~InteractiveFlag.drag - : InteractiveFlag.all; + _interactiveFlags = _interactiveFlags.copyWith( + drag: !_interactiveFlags.drag, + ); }); }, child: const Text('Change flags'), diff --git a/test/full_coverage_test.dart b/test/full_coverage_test.dart index 1816b0580..7883dfea0 100644 --- a/test/full_coverage_test.dart +++ b/test/full_coverage_test.dart @@ -2,12 +2,6 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/geo/crs.dart'; import 'package:flutter_map/src/geo/latlng_bounds.dart'; -import 'package:flutter_map/src/gestures/interactive_flag.dart'; -import 'package:flutter_map/src/gestures/latlng_tween.dart'; -import 'package:flutter_map/src/gestures/map_events.dart'; -import 'package:flutter_map/src/gestures/map_interactive_viewer.dart'; -import 'package:flutter_map/src/gestures/multi_finger_gesture.dart'; -import 'package:flutter_map/src/gestures/positioned_tap_detector_2.dart'; import 'package:flutter_map/src/layer/attribution_layer/rich/animation.dart'; import 'package:flutter_map/src/layer/attribution_layer/rich/source.dart'; import 'package:flutter_map/src/layer/attribution_layer/rich/widget.dart'; @@ -44,19 +38,21 @@ import 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; import 'package:flutter_map/src/map/camera/camera.dart'; import 'package:flutter_map/src/map/camera/camera_constraint.dart'; import 'package:flutter_map/src/map/camera/camera_fit.dart'; +import 'package:flutter_map/src/map/controller/events/map_event_source.dart'; +import 'package:flutter_map/src/map/controller/events/map_events.dart'; import 'package:flutter_map/src/map/controller/map_controller.dart'; import 'package:flutter_map/src/map/controller/map_controller_impl.dart'; +import 'package:flutter_map/src/map/gestures/latlng_tween.dart'; +import 'package:flutter_map/src/map/gestures/map_interactive_viewer.dart'; +import 'package:flutter_map/src/map/gestures/services/base_services.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; -import 'package:flutter_map/src/map/options/cursor_keyboard_rotation.dart'; -import 'package:flutter_map/src/map/options/interaction.dart'; -import 'package:flutter_map/src/map/options/options.dart'; +import 'package:flutter_map/src/map/options/interaction_options.dart'; +import 'package:flutter_map/src/map/options/map_gestures.dart'; +import 'package:flutter_map/src/map/options/map_options.dart'; import 'package:flutter_map/src/map/widget.dart'; import 'package:flutter_map/src/misc/bounds.dart'; -import 'package:flutter_map/src/misc/center_zoom.dart'; import 'package:flutter_map/src/misc/extensions.dart'; -import 'package:flutter_map/src/misc/move_and_rotate_result.dart'; import 'package:flutter_map/src/misc/offsets.dart'; -import 'package:flutter_map/src/misc/position.dart'; import 'package:flutter_map/src/misc/simplify.dart'; void main() {} diff --git a/test/misc/private/positioned_tap_detector_2_test.dart b/test/misc/private/positioned_tap_detector_2_test.dart deleted file mode 100644 index 7652e2f78..000000000 --- a/test/misc/private/positioned_tap_detector_2_test.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('Test tap is detected where expected.', (tester) async { - const screenSize = Size(400, 400); - await tester.binding.setSurfaceSize(screenSize); - - // PositionedTapDetector2 fills the full screen. - Offset? lastTap; - final widget = PositionedTapDetector2( - onTap: (position) => lastTap = position.relative, - child: SizedBox( - width: screenSize.width, - height: screenSize.height, - child: Container( - color: Colors.red, - ), - ), - ); - - await tester.pumpWidget(widget); - - Future tap(Offset pos) async { - lastTap = null; - await tester.tapAt(pos); - expect(lastTap, pos); - } - - // Tap top left - await tap(Offset.zero); - // Tap middle - await tap(Offset(screenSize.width / 2, screenSize.height / 2)); - // Tap bottom right - await tap(Offset(screenSize.width - 1, screenSize.height - 1)); - }); - - testWidgets('Test tap is detected where expected with scale and offset.', - (tester) async { - const screenSize = Size(400, 400); - await tester.binding.setSurfaceSize(screenSize); - - Offset? lastTap; - // The Transform.scale fills the screen, but the PositionedTapDetector2 - // occupies the center, with height of 200 (0.5 * 400) and width 400. - final widget = Transform.scale( - scaleY: 0.5, - child: PositionedTapDetector2( - onTap: (position) => lastTap = position.relative, - child: SizedBox( - width: screenSize.width, - height: screenSize.height, - child: Container( - color: Colors.red, - ), - ), - ), - ); - - await tester.pumpWidget(widget); - - // On the screen the PositionedTapDetector2 is actually 400x200, but the - // widget thinks its 400x400. - expect(screenSize, tester.getSize(find.byType(SizedBox))); - - Future tap(Offset pos, Offset expected) async { - lastTap = null; - await tester.tapAt(pos); - expect(lastTap, expected); - } - - // Tap top left of PositionedTapDetector2 which should be 0,0. - await tap(const Offset(0, 100), Offset.zero); - - // Tap bottom right of PositionedTapDetector2 - await tap(const Offset(400 - 1, 300 - 0.5), - Offset(screenSize.width - 1, screenSize.height - 1)); - }); -}