diff --git a/example/integration_test/controller_test.dart b/example/integration_test/controller_test.dart index 8c57bc63..9b3a06ea 100644 --- a/example/integration_test/controller_test.dart +++ b/example/integration_test/controller_test.dart @@ -25,7 +25,7 @@ void main() { await tester.pumpWidget(app); final ctrl = await ctrlCompleter.future; events.clear(); - await ctrl.moveCamera( + ctrl.moveCamera( center: const Geographic(lon: 1, lat: 1), bearing: 1, zoom: 1, @@ -742,7 +742,7 @@ void main() { final app = App(onMapCreated: ctrlCompleter.complete); await tester.pumpWidget(app); final ctrl = await ctrlCompleter.future; - await ctrl.moveCamera( + ctrl.moveCamera( center: const Geographic(lon: 1, lat: 2), bearing: 1, zoom: 1, diff --git a/example/integration_test/map_scalebar_test.dart b/example/integration_test/map_scalebar_test.dart index 38aa9399..53e471f6 100644 --- a/example/integration_test/map_scalebar_test.dart +++ b/example/integration_test/map_scalebar_test.dart @@ -33,7 +33,7 @@ void main() { final widths = []; for (final zoom in zoomLevels) { - await ctrl.moveCamera(zoom: zoom); + ctrl.moveCamera(zoom: zoom); await tester.pumpAndSettle(const Duration(seconds: 2)); final size = tester.getSize(customPaintFinder); diff --git a/example/lib/controller_page.dart b/example/lib/controller_page.dart index e7dc354a..79533088 100644 --- a/example/lib/controller_page.dart +++ b/example/lib/controller_page.dart @@ -31,7 +31,7 @@ class _ControllerPageState extends State { OutlinedButton( onPressed: () async { debugPrint('moveCamera start'); - await _controller.moveCamera( + _controller.moveCamera( center: const Geographic(lon: 172.4714, lat: -42.4862), zoom: 4, pitch: 0, diff --git a/example/lib/events_page.dart b/example/lib/events_page.dart index 6fad83c0..1c2ba9ef 100644 --- a/example/lib/events_page.dart +++ b/example/lib/events_page.dart @@ -44,25 +44,25 @@ class _EventsPageState extends State { } String eventToString(MapEvent event) => switch (event) { - MapEventMapCreated() => 'map created', - MapEventStyleLoaded() => 'style loaded', + MapEventMapCreated() => '[Map Created]', + MapEventStyleLoaded() => '[Style Loaded]', MapEventMoveCamera() => - 'move camera: center ${_formatGeographic(event.camera.center)}, ' + '[Move Camera] center ${_formatGeographic(event.camera.center)}, ' 'zoom ${event.camera.zoom.toStringAsFixed(2)}, ' 'pitch ${event.camera.pitch.toStringAsFixed(2)}, ' 'bearing ${event.camera.bearing.toStringAsFixed(2)}', MapEventStartMoveCamera() => - 'start move camera, reason: ${event.reason.name}', + '[Start Move Camera], reason: ${event.reason.name}', MapEventClick() => - 'clicked: ${_formatGeographic(event.point)}, screen: ${_formatOffset(event.screenPoint)}', + '[Click] ${_formatGeographic(event.point)}, screen: ${_formatOffset(event.screenPoint)}', MapEventDoubleClick() => - 'double clicked: ${_formatGeographic(event.point)}, screen: ${_formatOffset(event.screenPoint)}', + '[Double Click] ${_formatGeographic(event.point)}, screen: ${_formatOffset(event.screenPoint)}', MapEventLongClick() => - 'long clicked: ${_formatGeographic(event.point)}, screen: ${_formatOffset(event.screenPoint)}', + '[Long Click] ${_formatGeographic(event.point)}, screen: ${_formatOffset(event.screenPoint)}', MapEventSecondaryClick() => - 'secondary clicked: ${_formatGeographic(event.point)}, screen: ${_formatOffset(event.screenPoint)}', - MapEventIdle() => 'idle', - MapEventCameraIdle() => 'camera idle', + '[Secondary Click] ${_formatGeographic(event.point)}, screen: ${_formatOffset(event.screenPoint)}', + MapEventIdle() => '[Idle]', + MapEventCameraIdle() => '[Camera Idle]', }; void _onEvent(MapEvent event) { diff --git a/maplibre/lib/src/interaction/interaction_handler.dart b/maplibre/lib/src/interaction/interaction_handler.dart new file mode 100644 index 00000000..a36ed711 --- /dev/null +++ b/maplibre/lib/src/interaction/interaction_handler.dart @@ -0,0 +1,29 @@ +import 'package:maplibre/maplibre.dart'; +import 'package:maplibre/src/map_state.dart'; + +/// Base class for gesture handlers. +abstract class InteractionHandler { + /// Creates a gesture handler with the given [controller]. + const InteractionHandler(this.controller); + + /// Reference to the map controller. + final MapLibreMapState controller; + + /// Accessor to the map options. + MapOptions get options => controller.options; + + /// Accessor to the current map camera. + MapCamera get camera => controller.camera ?? controller.getCamera(); + + /// Accessor to the map gestures configuration. + MapGestures get gestures => options.gestures; + + /// Emits a [MapEventStartMoveCamera]. + void emitMoveStartEvent() => controller.widget.onEvent?.call( + const MapEventStartMoveCamera(reason: CameraChangeReason.apiGesture), + ); + + /// Emits a [MapEventCameraIdle]. + void emitCameraIdleEvent() => + controller.widget.onEvent?.call(const MapEventCameraIdle()); +} diff --git a/maplibre/lib/src/interaction/keyboard_handler.dart b/maplibre/lib/src/interaction/keyboard_handler.dart new file mode 100644 index 00000000..c31dbaa8 --- /dev/null +++ b/maplibre/lib/src/interaction/keyboard_handler.dart @@ -0,0 +1,98 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/services.dart'; +import 'package:maplibre/src/interaction/interaction_handler.dart'; +import 'package:maplibre/src/map_camera_tween.dart'; +import 'package:maplibre/src/map_state.dart'; + +/// Handles keyboard interactions. +class KeyboardHandler extends InteractionHandler { + /// Creates a keyboard interaction handler with the given [controller]. + KeyboardHandler(super.controller) { + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + } + + final _arrowKeys = { + PhysicalKeyboardKey.arrowUp, + PhysicalKeyboardKey.arrowDown, + PhysicalKeyboardKey.arrowLeft, + PhysicalKeyboardKey.arrowRight, + }; + + /// [MapLibreMapState.dispose] + void dispose() { + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + } + + /// Called when the animation status is completed. Returns true if the + /// interation is still ongoing. + bool onAnimationStatusCompleted() { + // restart animation if arrow keys are still pressed + return _updateKeyboardAnimation(); + } + + /// Handles keyboard events. Returns weather the event was handled. + bool _handleKeyEvent(KeyEvent event) { + // debugPrint('Keyboard event: $event'); + if (_arrowKeys.contains(event.physicalKey) && + (event is KeyDownEvent || event is KeyUpEvent)) { + _updateKeyboardAnimation(); + return true; + } + return false; + } + + bool _updateKeyboardAnimation() { + var direction = Offset.zero; + if (HardwareKeyboard.instance.isPhysicalKeyPressed( + PhysicalKeyboardKey.arrowUp, + )) { + direction += const Offset(0, -1); + } + if (HardwareKeyboard.instance.isPhysicalKeyPressed( + PhysicalKeyboardKey.arrowDown, + )) { + direction += const Offset(0, 1); + } + if (HardwareKeyboard.instance.isPhysicalKeyPressed( + PhysicalKeyboardKey.arrowLeft, + )) { + direction += const Offset(-1, 0); + } + if (HardwareKeyboard.instance.isPhysicalKeyPressed( + PhysicalKeyboardKey.arrowRight, + )) { + direction += const Offset(1, 0); + } + if (direction == Offset.zero) { + controller.animation = null; + controller.animationController.stop(canceled: false); + emitCameraIdleEvent(); + return false; + } + + // normalize direction + direction = Offset.fromDirection(direction.direction); + final camera = this.camera; + if (controller.animation == null) { + emitMoveStartEvent(); + } + controller.animation = + MapCameraTween( + begin: camera, + end: camera.copyWith( + center: controller.toLngLat( + controller.toScreenLocation(camera.center) + direction * 300, + ), + ), + ).animate( + CurvedAnimation( + parent: controller.animationController, + curve: Curves.linear, + ), + ); + controller.animationController + ..duration = const Duration(seconds: 1) + ..forward(from: 0); + return true; + } +} diff --git a/maplibre/lib/src/interaction/pointer_handler.dart b/maplibre/lib/src/interaction/pointer_handler.dart new file mode 100644 index 00000000..b4be348e --- /dev/null +++ b/maplibre/lib/src/interaction/pointer_handler.dart @@ -0,0 +1,228 @@ +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:maplibre/maplibre.dart'; +import 'package:maplibre/src/interaction/interaction_handler.dart'; +import 'package:maplibre/src/map_camera_tween.dart'; + +/// Handles multi-pointer gestures. +class PointerHandler extends InteractionHandler { + /// Creates a multi-pointer gesture handler with the given [controller]. + PointerHandler(super.controller); + + bool? _isTwoPointerPitch; + ScaleStartDetails? _onScaleStartEvent; + ScaleUpdateDetails? _lastScaleUpdateDetails; + ScaleUpdateDetails? _secondToLastScaleUpdateDetails; + + /// [GestureDetector.onScaleStart] + void onScaleStart(ScaleStartDetails details) { + // debugPrint('Scale start: $details'); + _onScaleStartEvent = details; + emitMoveStartEvent(); + } + + /// [GestureDetector.onScaleUpdate] + void onScaleUpdate(ScaleUpdateDetails details) { + // debugPrint('Scale update: $details'); + final camera = this.camera; + final startEvent = _onScaleStartEvent; + final pointerDownEvent = controller.pointerDownEvent; + if (startEvent == null || pointerDownEvent == null) return; + final doubleTapDown = controller.doubleTapDownDetails; + final lastEvent = _lastScaleUpdateDetails; + _secondToLastScaleUpdateDetails = _lastScaleUpdateDetails; + _lastScaleUpdateDetails = details; + final ctrlPressed = HardwareKeyboard.instance.isControlPressed; + final buttons = pointerDownEvent.buttons; + final lastPointerOffset = lastEvent?.focalPoint ?? startEvent.focalPoint; + + if (doubleTapDown != null && options.gestures.zoom) { + // double tap drag: zoom + // debugPrint('Double tap drag zoom detected $doubleTapDown'); + final lastY = lastEvent?.focalPoint.dy ?? startEvent.focalPoint.dy; + final iOS = Theme.of(controller.context).platform == TargetPlatform.iOS; + var deltaY = details.focalPoint.dy - lastY; + if (iOS) deltaY = -deltaY; + final newZoom = camera.zoom + deltaY * 0.01; // sensitivity + controller.moveCamera( + zoom: newZoom.clamp(options.minZoom, options.maxZoom), + ); + } else if ((buttons & kSecondaryMouseButton) != 0 || ctrlPressed) { + // secondary button: pitch and bearing + final delta = details.focalPoint - lastPointerOffset; + var newBearing = camera.bearing; + if (options.gestures.rotate) { + newBearing = camera.bearing + delta.dx * 0.5; // sensitivity + } + var newPitch = camera.pitch; + if (options.gestures.pitch) { + newPitch = camera.pitch - delta.dy * 0.5; // sensitivity; + } + final newZoom = camera.zoom; + if (options.gestures.zoom) { + // TODO adjust newZoom for globe projection + } + controller.moveCamera( + bearing: newBearing, + pitch: newPitch, + zoom: newZoom, + ); + } else if ((buttons & kPrimaryMouseButton) != 0) { + // primary button: pan, zoom, bearing, pinch + if (_isTwoPointerPitch == null) { + if (options.gestures.pitch && controller.pointers.length >= 2) { + final pointers = controller.pointers.values + .toList(growable: false) + .take(2) + .toList(growable: false); + final delta = pointers.first - pointers.last; + final pointersAlignedVertically = delta.dy.abs() < delta.dx.abs(); + final movingVertically = + (details.focalPoint - lastPointerOffset).dy.abs() > + (details.focalPoint - lastPointerOffset).dx.abs(); + _isTwoPointerPitch = pointersAlignedVertically && movingVertically; + } else { + _isTwoPointerPitch = false; + } + } + final pitch = _isTwoPointerPitch!; + + // zoom + var newZoom = camera.zoom; + final lastScale = lastEvent?.scale ?? 1.0; + const scaleSensitivity = 0.9; + final scaleDelta = (details.scale - lastScale) * scaleSensitivity; + if (scaleDelta != 0 && options.gestures.zoom && !pitch) { + newZoom = camera.zoom + scaleDelta; + } + + // bearing + var newBearing = camera.bearing; + final lastRotation = lastEvent?.rotation ?? 0.0; + final rotationDelta = details.rotation - lastRotation; + final rotationDegree = rotationDelta * radian2Degree; + if (options.gestures.rotate && details.rotation != 0.0 && !pitch) { + newBearing -= rotationDegree; + } + + // center + var newCenter = camera.center; + if (options.gestures.pan && !pitch) { + final delta = details.focalPoint - lastPointerOffset; + final centerOffset = controller.toScreenLocation(camera.center); + var newCenterOffset = centerOffset - delta; + if (options.gestures.rotate) { + // rotate around details.focalPoint + newCenterOffset = Offset( + cos(-rotationDelta) * (newCenterOffset.dx - details.focalPoint.dx) - + sin(-rotationDelta) * + (newCenterOffset.dy - details.focalPoint.dy) + + details.focalPoint.dx, + sin(-rotationDelta) * (newCenterOffset.dx - details.focalPoint.dx) + + cos(-rotationDelta) * + (newCenterOffset.dy - details.focalPoint.dy) + + details.focalPoint.dy, + ); + } + newCenter = controller.toLngLat(newCenterOffset); + if (options.gestures.zoom) { + newCenter = newCenter.intermediatePointTo( + controller.toLngLat(details.focalPoint), + fraction: scaleDelta * 0.8, // zoom towards focal point + ); + } + } + + // pitch + var newPitch = camera.pitch; + if (options.gestures.pitch && pitch) { + final delta = details.focalPoint - lastPointerOffset; + newPitch = camera.pitch - delta.dy * 0.5; // sensitivity; + } + + controller.moveCamera( + zoom: newZoom, + center: newCenter, + bearing: newBearing, + pitch: newPitch, + ); + } + } + + /// [GestureDetector.onScaleEnd] + void onScaleEnd(ScaleEndDetails details) { + // debugPrint('Scale end: $details'); + final camera = this.camera; + final firstEvent = _onScaleStartEvent; + final secondToLastEvent = _secondToLastScaleUpdateDetails; + final lastEvent = _lastScaleUpdateDetails; + if (firstEvent == null) return; + + // zoom out + if (lastEvent == null && + options.gestures.zoom && + firstEvent.pointerCount == 2) { + var newCenter = camera.center; + if (options.gestures.pan) { + newCenter = controller + .toLngLat( + firstEvent.focalPoint, + ) + .intermediatePointTo(camera.center, fraction: 0.2); + } + emitMoveStartEvent(); + controller.animateCamera(zoom: camera.zoom - 1, center: newCenter); + } else if (secondToLastEvent != null && + lastEvent != null && + options.gestures.pan) { + // fling animation + final velocity = details.velocity.pixelsPerSecond.distance; + if (velocity >= 800) { + final offset = secondToLastEvent.focalPoint - lastEvent.focalPoint; + final distance = offset.distance; + final direction = + offset.direction * radian2Degree + 90 + camera.bearing; + final tweens = MapCameraTween( + begin: camera, + end: camera.copyWith( + center: camera.center.destinationPoint2D( + distance: distance, + bearing: direction, + ), + ), + ); + controller.animation = tweens.animate( + CurvedAnimation( + parent: controller.animationController, + curve: Curves.easeOut, + ), + ); + controller.animationController + ..duration = Duration( + milliseconds: (distance / velocity * 1000).round(), + ) + ..value = 0 + ..fling( + velocity: velocity / 2000, + springDescription: SpringDescription.withDampingRatio( + mass: 1, + stiffness: 1000, + ratio: 5, + ), + ); + } else { + // interation ended without fling animation + emitCameraIdleEvent(); + } + } + + controller.doubleTapDownDetails = null; + _onScaleStartEvent = null; + _lastScaleUpdateDetails = null; + _secondToLastScaleUpdateDetails = null; + _isTwoPointerPitch = null; + } +} diff --git a/maplibre/lib/src/interaction/scroll_wheel_zoom_handler.dart b/maplibre/lib/src/interaction/scroll_wheel_zoom_handler.dart new file mode 100644 index 00000000..56feebbf --- /dev/null +++ b/maplibre/lib/src/interaction/scroll_wheel_zoom_handler.dart @@ -0,0 +1,47 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:maplibre/src/interaction/interaction_handler.dart'; +import 'package:maplibre/src/map_camera_tween.dart'; + +/// Handles mouse scroll wheel zooming. +class ScrollWheelZoomHandler extends InteractionHandler { + /// Creates a scroll wheel zoom handler with the given [controller]. + ScrollWheelZoomHandler(super.controller); + + /// [Listener.onPointerSignal] + void onPointerScrollSignal(PointerScrollEvent event) { + // debugPrint('Scroll wheel event: ${event.scrollDelta.dy}'); + if (!options.gestures.zoom) return; + + final currCamera = camera; + final zoomChange = -event.scrollDelta.dy / 300; // sensitivity + final prevTarget = controller.targetCamera ?? currCamera; + + var targetZoom = prevTarget.zoom; + if (options.gestures.zoom) { + targetZoom = prevTarget.zoom + zoomChange; + } + var targetCenter = prevTarget.center; + if (options.gestures.pan) { + targetCenter = prevTarget.center.intermediatePointTo( + controller.toLngLat(event.localPosition), + fraction: zoomChange > 0 ? 0.2 : -0.2, + ); + } + final targetCamera = controller.targetCamera = currCamera.copyWith( + zoom: targetZoom, + center: targetCenter, + ); + final tweens = MapCameraTween(begin: currCamera, end: targetCamera); + if (!controller.animationController.isAnimating) { + emitMoveStartEvent(); + } + controller.animation = tweens.animate( + CurvedAnimation( + parent: controller.animationController, + curve: Curves.easeOut, + ), + ); + controller.animationController.forward(from: 0); + } +} diff --git a/maplibre/lib/src/interaction/tap_handler.dart b/maplibre/lib/src/interaction/tap_handler.dart new file mode 100644 index 00000000..127f7f4d --- /dev/null +++ b/maplibre/lib/src/interaction/tap_handler.dart @@ -0,0 +1,109 @@ +import 'package:flutter/widgets.dart'; +import 'package:maplibre/maplibre.dart'; +import 'package:maplibre/src/interaction/interaction_handler.dart'; +import 'package:maplibre/src/map_camera_tween.dart'; + +/// Handles tap and double-tap interactions on the map. +class TapHandler extends InteractionHandler { + /// Creates a [TapHandler] instance. + TapHandler(super.controller); + + TapDownDetails? _tapDownDetails; + TapDownDetails? _secondaryTapDownDetails; + + /// [GestureDetector.onDoubleTap] + void onDoubleTap() { + // debugPrint('Double tap detected'); + final details = controller.doubleTapDownDetails; + controller.doubleTapDownDetails = null; + if (details == null) return; + + final event = MapEventDoubleClick( + point: controller.toLngLat(details.localPosition), + screenPoint: details.localPosition, + ); + controller.widget.onEvent?.call(event); + + if (options.gestures.zoom) { + final camera = this.camera; + final newCenter = (controller.targetCamera?.center ?? camera.center) + .intermediatePointTo( + controller.toLngLat(details.localPosition), + fraction: 0.5, + ); + + // zoom in on double tap + final tweens = MapCameraTween( + begin: camera, + end: camera.copyWith( + zoom: camera.zoom + 1, + center: newCenter, + ), + ); + controller.animation = tweens.animate( + CurvedAnimation( + parent: controller.animationController, + curve: Curves.easeInOut, + ), + ); + emitMoveStartEvent(); + controller.animationController + ..duration = const Duration(milliseconds: 300) + ..forward(from: 0); + } + } + + /// [GestureDetector.onDoubleTapDown] + // ignore: use_setters_to_change_properties + void onDoubleTapDown(TapDownDetails details) { + // zoom in or out on double tap down + // debugPrint('Double tap down at position: ${details.localPosition}'); + controller.doubleTapDownDetails = details; + } + + /// [GestureDetector.onDoubleTapCancel] + void onDoubleTapCancel() { + // debugPrint('Double tap cancelled'); + } + + /// [GestureDetector.onTapDown] + // ignore: use_setters_to_change_properties + void onTapDown(TapDownDetails details) { + _tapDownDetails = details; + } + + /// [GestureDetector.onTap] + void onTap() { + debugPrint('Tap detected'); + final details = _tapDownDetails; + if (details == null) return; + controller.widget.onEvent?.call( + MapEventClick( + point: controller.toLngLat(details.localPosition), + screenPoint: details.localPosition, + ), + ); + } + + /// [GestureDetector.onTapCancel] + void onTapCancel() { + debugPrint('Tap cancelled'); + } + + /// [GestureDetector.onSecondaryTapDown] + // ignore: use_setters_to_change_properties + void onSecondaryTapDown(TapDownDetails details) { + _secondaryTapDownDetails = details; + } + + /// [GestureDetector.onSecondaryTap] + void onSecondaryTap() { + final details = _secondaryTapDownDetails; + if (details == null) return; + final event = MapEventClick( + point: controller.toLngLat(details.localPosition), + screenPoint: details.localPosition, + ); + controller.widget.onEvent?.call(event); + } +} diff --git a/maplibre/lib/src/map_camera.dart b/maplibre/lib/src/map_camera.dart index 88ebd439..ff6e6261 100644 --- a/maplibre/lib/src/map_camera.dart +++ b/maplibre/lib/src/map_camera.dart @@ -56,4 +56,18 @@ class MapCamera { @override int get hashCode => Object.hash(center, zoom, bearing, pitch); + + /// Returns a copy of this [MapCamera] with the given fields replaced by the + /// new values. + MapCamera copyWith({ + Geographic? center, + double? zoom, + double? bearing, + double? pitch, + }) => MapCamera( + center: center ?? this.center, + zoom: zoom ?? this.zoom, + bearing: bearing ?? this.bearing, + pitch: pitch ?? this.pitch, + ); } diff --git a/maplibre/lib/src/map_camera_tween.dart b/maplibre/lib/src/map_camera_tween.dart new file mode 100644 index 00000000..e0c163ea --- /dev/null +++ b/maplibre/lib/src/map_camera_tween.dart @@ -0,0 +1,21 @@ +import 'dart:ui'; + +import 'package:flutter/animation.dart'; +import 'package:maplibre/maplibre.dart'; + +/// A tween for smoothly interpolating between two [MapCamera] states. +class MapCameraTween extends Tween { + /// Creates a [MapCameraTween] with the given [begin] and [end] camera states. + MapCameraTween({required MapCamera begin, required MapCamera end}) + : super(begin: begin, end: end); + + @override + MapCamera lerp(double t) { + return MapCamera( + center: begin!.center.intermediatePointTo(end!.center, fraction: t), + zoom: lerpDouble(begin!.zoom, end!.zoom, t)!, + bearing: lerpDouble(begin!.bearing, end!.bearing, t)!, + pitch: lerpDouble(begin!.pitch, end!.pitch, t)!, + ); + } +} diff --git a/maplibre/lib/src/map_controller.dart b/maplibre/lib/src/map_controller.dart index 64f6dd0f..27d408d9 100644 --- a/maplibre/lib/src/map_controller.dart +++ b/maplibre/lib/src/map_controller.dart @@ -42,7 +42,7 @@ abstract interface class MapController { List toLngLats(List screenLocations); /// Instantly move the map camera to a new location. - Future moveCamera({ + void moveCamera({ Geographic? center, double? zoom, double? bearing, diff --git a/maplibre/lib/src/map_state.dart b/maplibre/lib/src/map_state.dart index b0e62c57..4b1a8f24 100644 --- a/maplibre/lib/src/map_state.dart +++ b/maplibre/lib/src/map_state.dart @@ -1,10 +1,16 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; import 'package:maplibre/maplibre.dart'; import 'package:maplibre/src/inherited_model.dart'; +import 'package:maplibre/src/interaction/keyboard_handler.dart'; +import 'package:maplibre/src/interaction/pointer_handler.dart'; +import 'package:maplibre/src/interaction/scroll_wheel_zoom_handler.dart'; +import 'package:maplibre/src/interaction/tap_handler.dart'; import 'package:maplibre/src/layer/layer_manager.dart'; /// The [State] of the [MapLibreMap] widget. abstract class MapLibreMapState extends State + with TickerProviderStateMixin implements MapController { /// The counter is used to ensure an unique [viewName] for the platform view. static int _counter = 0; @@ -15,6 +21,16 @@ abstract class MapLibreMapState extends State /// The [LayerManager] handles the high level markers, polygons, /// circles and polylines. LayerManager? layerManager; + late final ScrollWheelZoomHandler _scrollWheelZoomHandler; + late final PointerHandler _pointerHandler; + late final KeyboardHandler _keyboardHandler; + late final TapHandler _tapHandler; + + /// The [TapDownDetails] of the last double tap down event. + TapDownDetails? doubleTapDownDetails; + + /// The [PointerDownEvent] of the first active pointer on the map. + PointerDownEvent? pointerDownEvent; /// Get the [MapOptions] from [MapLibreMap.options]. @override @@ -27,17 +43,87 @@ abstract class MapLibreMapState extends State /// is set. bool isInitialized = false; + /// Currently active pointers on the map. + final Map pointers = {}; + + /// Animation controller for camera animations. + late final animationController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ) + ..addListener(_onAnimation) + ..addStatusListener(_onAnimationStatus); + + /// Current camera animation, if any. + Animation? animation; + + /// Target camera for ongoing interactions. + MapCamera? targetCamera; + + @override + void initState() { + _scrollWheelZoomHandler = ScrollWheelZoomHandler(this); + _pointerHandler = PointerHandler(this); + _keyboardHandler = KeyboardHandler(this); + _tapHandler = TapHandler(this); + super.initState(); + } + @override Widget build(BuildContext context) { return Stack( children: [ buildPlatformWidget(context), - MapLibreInheritedModel( - mapController: this, - mapCamera: camera, - child: isInitialized - ? Stack(children: widget.children) - : const SizedBox.shrink(), + Listener( + onPointerDown: (event) { + pointerDownEvent = event; + pointers[event.pointer] = event.position; + _stopAnimation(); + }, + onPointerMove: (event) { + pointers[event.pointer] = event.position; + }, + onPointerUp: (event) { + pointers.remove(event.pointer); + if (pointers.isEmpty) pointerDownEvent = null; + }, + onPointerCancel: (event) { + pointers.remove(event.pointer); + if (pointers.isEmpty) pointerDownEvent = null; + }, + onPointerSignal: (event) { + switch (event) { + case final PointerScrollEvent event: + _scrollWheelZoomHandler.onPointerScrollSignal(event); + } + }, + child: GestureDetector( + onTapDown: (details) => _tapHandler.onTapDown, + onTap: () => _tapHandler.onTap, + onTapCancel: () => _tapHandler.onTapCancel, + onSecondaryTapDown: (details) => _tapHandler.onSecondaryTapDown, + onSecondaryTap: () => _tapHandler.onSecondaryTap, + onDoubleTapDown: _tapHandler.onDoubleTapDown, + onDoubleTapCancel: _tapHandler.onDoubleTapCancel, + onDoubleTap: _tapHandler.onDoubleTap, + // pan and scale, scale is a superset of the pan gesture + onScaleStart: _pointerHandler.onScaleStart, + onScaleUpdate: _pointerHandler.onScaleUpdate, + onScaleEnd: _pointerHandler.onScaleEnd, + // This transparent ColoredBox is needed to make sure the + // GestureDetector has a size and can detect gestures. + child: ColoredBox( + color: Colors.transparent, + child: MapLibreInheritedModel( + mapController: this, + mapCamera: camera, + child: isInitialized + ? Stack(children: widget.children) + : const SizedBox.expand(), + ), + ), + ), ), ], ); @@ -45,4 +131,37 @@ abstract class MapLibreMapState extends State /// Build the platform specific widget. Widget buildPlatformWidget(BuildContext context); + + void _onAnimation() { + moveCamera( + zoom: animation!.value.zoom, + center: animation!.value.center, + bearing: animation!.value.bearing, + pitch: animation!.value.pitch, + ); + } + + @override + void dispose() { + animation?.removeListener(_onAnimation); + animationController.dispose(); + _keyboardHandler.dispose(); + super.dispose(); + } + + void _stopAnimation() { + animationController.stop(); + animation = null; + targetCamera = null; + } + + void _onAnimationStatus(AnimationStatus status) { + // debugPrint('Animation status: $status'); + if (status == AnimationStatus.completed && animation != null) { + final ongoingInteraction = _keyboardHandler.onAnimationStatusCompleted(); + if (!ongoingInteraction) { + widget.onEvent?.call(const MapEventCameraIdle()); + } + } + } } diff --git a/maplibre/lib/src/platform/android/map_state.dart b/maplibre/lib/src/platform/android/map_state.dart index de31a900..2a888cf2 100644 --- a/maplibre/lib/src/platform/android/map_state.dart +++ b/maplibre/lib/src/platform/android/map_state.dart @@ -310,12 +310,12 @@ final class MapLibreMapStateAndroid extends MapLibreMapStateNative }); @override - Future moveCamera({ + void moveCamera({ Geographic? center, double? zoom, double? bearing, double? pitch, - }) async => using((arena) { + }) => using((arena) { assert(_jMap != null, '_jMapLibreMap needs to be not null.'); final cameraPosBuilder = jni.CameraPosition$Builder()..releasedBy(arena); if (center != null) cameraPosBuilder.target(center.toLatLng()); @@ -327,14 +327,7 @@ final class MapLibreMapStateAndroid extends MapLibreMapStateNative final cameraUpdate = jni.CameraUpdateFactory.newCameraPosition( cameraPosition, )..releasedBy(arena); - final completer = Completer(); - _jMap?.moveCamera$1( - cameraUpdate, - jni.MapLibreMap$CancelableCallback.implement( - _CameraMovementCallback(WeakReference(completer)), - )..releasedBy(arena), - ); - return completer.future; + _jMap?.moveCamera(cameraUpdate); }); @override diff --git a/maplibre/lib/src/platform/android/style_controller.dart b/maplibre/lib/src/platform/android/style_controller.dart index a37b72f2..0097431f 100644 --- a/maplibre/lib/src/platform/android/style_controller.dart +++ b/maplibre/lib/src/platform/android/style_controller.dart @@ -262,4 +262,7 @@ class StyleControllerAndroid extends StyleController { void setProjection(MapProjection projection) { // globe is not supported on android. } + + @override + MapProjection get projection => MapProjection.mercator; } diff --git a/maplibre/lib/src/platform/ios/style_controller.dart b/maplibre/lib/src/platform/ios/style_controller.dart index b762b368..30942c8e 100644 --- a/maplibre/lib/src/platform/ios/style_controller.dart +++ b/maplibre/lib/src/platform/ios/style_controller.dart @@ -287,4 +287,7 @@ class StyleControllerIos extends StyleController { } return attributions; } + + @override + MapProjection get projection => MapProjection.mercator; } diff --git a/maplibre/lib/src/platform/web/interop/events.dart b/maplibre/lib/src/platform/web/interop/events.dart index 2ad1fca2..e4113bd3 100644 --- a/maplibre/lib/src/platform/web/interop/events.dart +++ b/maplibre/lib/src/platform/web/interop/events.dart @@ -60,3 +60,13 @@ abstract class MapEventType { /// Called once the style has loaded. static const styleLoad = 'style.load'; } + +/// Event that fires for example when the context menu is triggered. +@JS() +extension type PointerEvent._(JSObject _) implements JSObject { + /// Create a new [PointerEvent]. + external PointerEvent(); + + /// Prevent the default action associated with the event. + external JSFunction preventDefault(); +} diff --git a/maplibre/lib/src/platform/web/interop/map.dart b/maplibre/lib/src/platform/web/interop/map.dart index d83316d9..b13f604e 100644 --- a/maplibre/lib/src/platform/web/interop/map.dart +++ b/maplibre/lib/src/platform/web/interop/map.dart @@ -139,6 +139,11 @@ extension type JsMap._(Camera _) implements Camera { JSArray rect, JSAny? options, ); + + /// Get the current map projection. + /// + /// https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#getprojection + external ProjectionSpecification getProjection(); } /// Anonymous MapOptions for the MapLibre JavaScript [JsMap]. @@ -256,6 +261,8 @@ extension type ProjectionSpecification._(JSObject _) implements JSObject { /// transition state, or an expression. required String type, }); + + external String type; } /// StyleSwapOptions diff --git a/maplibre/lib/src/platform/web/map_state.dart b/maplibre/lib/src/platform/web/map_state.dart index 935fc3d9..8c288659 100644 --- a/maplibre/lib/src/platform/web/map_state.dart +++ b/maplibre/lib/src/platform/web/map_state.dart @@ -22,7 +22,6 @@ final class MapLibreMapStateWeb extends MapLibreMapState { late HTMLDivElement _htmlElement; late interop.JsMap _map; Completer? _movementCompleter; - bool _nextGestureCausedByController = false; LayerManager? _layerManager; /// Get the [MapOptions] from [MapLibreMap.options]. @@ -34,141 +33,110 @@ final class MapLibreMapStateWeb extends MapLibreMapState { @override void initState() { - platformViewRegistry.registerViewFactory(viewName, ( - int viewId, [ - dynamic params, - ]) { - _htmlElement = HTMLDivElement() - ..style.padding = '0' - ..style.margin = '0' - ..style.height = '100%' - ..style.width = '100%'; - - // add pmtiles support - try { - final pmtilesProtocol = pmtiles.Protocol(); - interop.addProtocol('pmtiles', pmtilesProtocol.tile); - } catch (e) { - debugPrint('[MapLibre] PMTiles support could not be loaded. $e'); - } + platformViewRegistry.registerViewFactory(viewName, _onRegisterViewFactory); + super.initState(); + } - _map = interop.JsMap( - interop.MapOptions( - container: _htmlElement, - style: _prepareStyleString(options.initStyle), - zoom: options.initZoom, - center: options.initCenter?.toLngLat(), - bearing: options.initBearing, - pitch: options.initPitch, - attributionControl: false, - ), - ); - - document.body?.appendChild(_htmlElement); - // Invoke the onMapCreated callback async to avoid getting it called - // during the widget build. - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onEvent?.call(MapEventMapCreated(mapController: this)); - widget.onMapCreated?.call(this); - setState(() => isInitialized = true); - }); - _resizeMap(); - - // set options - _map.setMinZoom(options.minZoom); - _map.setMaxZoom(options.maxZoom); - _map.setMinPitch(options.minPitch); - _map.setMaxPitch(options.maxPitch); - _map.setMaxBounds(options.maxBounds?.toJsLngLatBounds()); - _updateGestures(options.gestures); - - // add callbacks - _map.on( - interop.MapEventType.load, - (interop.MapMouseEvent event) { - _onStyleLoaded(); - }.toJS, - ); - _map.on( - interop.MapEventType.click, - (interop.MapMouseEvent event) { - final point = event.lngLat.toGeographic(); - widget.onEvent?.call( - MapEventClick(point: point, screenPoint: event.point.toOffset()), - ); - }.toJS, - ); - _map.on( - interop.MapEventType.dblclick, - (interop.MapMouseEvent event) { - final point = event.lngLat.toGeographic(); - widget.onEvent?.call( - MapEventDoubleClick( - point: point, - screenPoint: event.point.toOffset(), - ), - ); - }.toJS, - ); - _map.on( - interop.MapEventType.contextmenu, - (interop.MapMouseEvent event) { - final point = event.lngLat.toGeographic(); - widget.onEvent?.call( - MapEventSecondaryClick( - point: point, - screenPoint: event.point.toOffset(), - ), - ); - }.toJS, - ); - _map.on( - interop.MapEventType.idle, - (interop.MapMouseEvent event) { - widget.onEvent?.call(const MapEventIdle()); - }.toJS, - ); - _map.on( - interop.MapEventType.moveStart, - (interop.MapLibreEvent event) { - final CameraChangeReason reason; - if (_nextGestureCausedByController) { - _nextGestureCausedByController = false; - reason = CameraChangeReason.developerAnimation; - } else if (event.originalEvent != null) { - reason = CameraChangeReason.apiGesture; - } else { - reason = CameraChangeReason.apiAnimation; - } - widget.onEvent?.call(MapEventStartMoveCamera(reason: reason)); - }.toJS, - ); - _map.on( - interop.MapEventType.move, - (interop.MapLibreEvent event) { - final mapCamera = MapCamera( - center: _map.getCenter().toGeographic(), - zoom: _map.getZoom().toDouble(), - pitch: _map.getPitch().toDouble(), - bearing: _map.getBearing().toDouble(), - ); - setState(() => camera = mapCamera); - widget.onEvent?.call(MapEventMoveCamera(camera: mapCamera)); - }.toJS, - ); - _map.on( - interop.MapEventType.moveEnd, - (interop.MapLibreEvent event) { - widget.onEvent?.call(const MapEventCameraIdle()); - if (!(_movementCompleter?.isCompleted ?? true)) { - _movementCompleter?.complete(event); - } - }.toJS, - ); - - return _htmlElement; + HTMLElement _onRegisterViewFactory(int viewId, [dynamic params]) { + _htmlElement = HTMLDivElement() + ..style.padding = '0' + ..style.margin = '0' + ..style.height = '100%' + ..style.width = '100%'; + + // add pmtiles support + try { + final pmtilesProtocol = pmtiles.Protocol(); + interop.addProtocol('pmtiles', pmtilesProtocol.tile); + } catch (e) { + // silence error if pmtiles support is not added + // debugPrint('[MapLibre] PMTiles support could not be loaded. $e'); + } + + _htmlElement.addEventListener( + 'contextmenu', + (Event event) { + // debugPrint('context menu event prevented'); + event.preventDefault(); + }.toJS, + ); + + _map = interop.JsMap( + interop.MapOptions( + container: _htmlElement, + style: _prepareStyleString(options.initStyle), + zoom: options.initZoom, + center: options.initCenter?.toLngLat(), + bearing: options.initBearing, + pitch: options.initPitch, + attributionControl: false, + ), + ); + + document.body?.appendChild(_htmlElement); + // Invoke the onMapCreated callback async to avoid getting it called + // during the widget build. + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onEvent?.call(MapEventMapCreated(mapController: this)); + widget.onMapCreated?.call(this); + setState(() => isInitialized = true); }); - super.initState(); + _resizeMap(); + + // set options + _map.setMinZoom(options.minZoom); + _map.setMaxZoom(options.maxZoom); + _map.setMinPitch(options.minPitch); + _map.setMaxPitch(options.maxPitch); + _map.setMaxBounds(options.maxBounds?.toJsLngLatBounds()); + // disable gestures because we handle them in Flutter + _map.dragPan.disable(); + _map.touchZoomRotate.disable(); + _map.doubleClickZoom.disable(); + _map.scrollZoom.disable(); + _map.boxZoom.disable(); + _map.dragRotate.disable(); + _map.touchPitch.disable(); + _map.keyboard.disable(); + + // add callbacks + _map.on( + interop.MapEventType.load, + (interop.MapMouseEvent event) { + _onStyleLoaded(); + }.toJS, + ); + _map.on( + interop.MapEventType.idle, + (interop.MapMouseEvent event) { + if (!animationController.isAnimating) { + widget.onEvent?.call(const MapEventIdle()); + } + }.toJS, + ); + _map.on( + interop.MapEventType.move, + (interop.MapLibreEvent event) { + final mapCamera = MapCamera( + center: _map.getCenter().toGeographic(), + zoom: _map.getZoom().toDouble(), + pitch: _map.getPitch().toDouble(), + bearing: _map.getBearing().toDouble(), + ); + setState(() => camera = mapCamera); + widget.onEvent?.call(MapEventMoveCamera(camera: mapCamera)); + }.toJS, + ); + _map.on( + interop.MapEventType.moveEnd, + (interop.MapLibreEvent event) { + if (!(_movementCompleter?.isCompleted ?? true)) { + _movementCompleter?.complete(event); + } + }.toJS, + ); + + return _htmlElement; } @override @@ -209,9 +177,6 @@ final class MapLibreMapStateWeb extends MapLibreMapState { if (options.maxBounds != oldWidget.options.maxBounds) { _map.setMaxBounds(options.maxBounds?.toJsLngLatBounds()); } - if (options.gestures != oldWidget.options.gestures) { - _updateGestures(options.gestures); - } _layerManager?.updateLayers(widget.layers); super.didUpdateWidget(oldWidget); } @@ -241,7 +206,6 @@ final class MapLibreMapStateWeb extends MapLibreMapState { double? bearing, double? pitch, }) async { - _nextGestureCausedByController = true; final camera = getCamera(); _map.jumpTo( interop.JumpToOptions( @@ -264,7 +228,6 @@ final class MapLibreMapStateWeb extends MapLibreMapState { Duration? webMaxDuration, }) async { final destination = center?.toLngLat(); - _nextGestureCausedByController = true; final camera = getCamera(); _map.flyTo( interop.FlyToOptions( @@ -357,45 +320,6 @@ final class MapLibreMapStateWeb extends MapLibreMapState { ); } - void _updateGestures(MapGestures gestures) { - if (gestures.pan) { - _map.dragPan.enable(); - } else { - _map.dragPan.disable(); - } - if (gestures.zoom) { - _map.touchZoomRotate.enable(); - _map.doubleClickZoom.enable(); - _map.scrollZoom.enable(); - _map.boxZoom.enable(); - } else { - _map.touchZoomRotate.disable(); // this disables rotation as well - _map.doubleClickZoom.disable(); - _map.scrollZoom.disable(); - _map.boxZoom.disable(); - } - if (gestures.rotate) { - _map.dragRotate.enable(); - _map.touchZoomRotate.enableRotation(); - } else { - _map.touchZoomRotate.disableRotation(); - _map.dragRotate.disable(); - } - if (gestures.pitch) { - // TODO dragRotate allows to pitch too but has no option to disable pitch. - _map.touchPitch.enable(); - } else { - _map.touchPitch.disable(); - } - // It's not possible to disable just some gestures for the KeyboardHandler. - // That's why we disable it completely if not all gestures are enabled. - if (gestures.allEnabled) { - _map.keyboard.enable(); - } else { - _map.keyboard.disable(); - } - } - @override Future enableLocation({ Duration fastestInterval = const Duration(milliseconds: 750), diff --git a/maplibre/lib/src/platform/web/style_controller.dart b/maplibre/lib/src/platform/web/style_controller.dart index 5cb7fbbe..e961a19c 100644 --- a/maplibre/lib/src/platform/web/style_controller.dart +++ b/maplibre/lib/src/platform/web/style_controller.dart @@ -295,4 +295,13 @@ class StyleControllerWeb extends StyleController { void setProjection(MapProjection projection) => _map.setProjection( interop.ProjectionSpecification(type: projection.name), ); + + @override + MapProjection get projection { + final type = _map.getProjection().type; + return MapProjection.values.firstWhere( + (e) => e.name == type, + orElse: () => MapProjection.mercator, + ); + } } diff --git a/maplibre/lib/src/style_controller.dart b/maplibre/lib/src/style_controller.dart index 76e4c3b8..3863e2b1 100644 --- a/maplibre/lib/src/style_controller.dart +++ b/maplibre/lib/src/style_controller.dart @@ -127,6 +127,9 @@ abstract class StyleController { /// [MapProjection.globe] is currently on supported on web. void setProjection(MapProjection projection); + /// Get the map projection. + MapProjection get projection; + /// Clean up resources. void dispose(); } diff --git a/maplibre/lib/src/utils.dart b/maplibre/lib/src/utils.dart index 9da74e68..516cb71b 100644 --- a/maplibre/lib/src/utils.dart +++ b/maplibre/lib/src/utils.dart @@ -3,6 +3,9 @@ import 'dart:math'; /// pre compiled factor to convert a coordinate in radian to degrees. const double degree2Radian = pi / 180; +/// pre compiled factor to convert a coordinate in degrees to radian. +const double radian2Degree = 180 / pi; + /// circumference of the Earth const circumferenceOfEarth = 40075016.686;