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/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_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..c24c9d93 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 'dart:ui'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:maplibre/maplibre.dart'; import 'package:maplibre/src/inherited_model.dart'; import 'package:maplibre/src/layer/layer_manager.dart'; +import 'package:pointer_interceptor/pointer_interceptor.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; @@ -27,17 +33,66 @@ abstract class MapLibreMapState extends State /// is set. bool isInitialized = false; + late final _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + )..addListener(_onAnimation); + final Map _pointers = {}; + Animation? _animation; + ScaleStartDetails? _onScaleStartEvent; + TapDownDetails? _doubleTapDownDetails; + ScaleUpdateDetails? _lastScaleUpdateDetails; + ScaleUpdateDetails? _secondToLastScaleUpdateDetails; + PointerDownEvent? _pointerDownEvent; + MapCamera? _targetCamera; + @override Widget build(BuildContext context) { return Stack( children: [ buildPlatformWidget(context), - MapLibreInheritedModel( - mapController: this, - mapCamera: camera, - child: isInitialized - ? Stack(children: widget.children) - : const SizedBox.shrink(), + PointerInterceptor( + child: 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; + }, + onPointerSignal: (event) { + switch (event) { + case final PointerScrollEvent event: + _scrollWheelZoom(event); + } + }, + child: GestureDetector( + onDoubleTapDown: _onDoubleTapDown, + onDoubleTapCancel: _onDoubleTapCancel, + onDoubleTap: _onDoubleTap, + // pan and scale, scale is a superset of the pan gesture + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + onScaleEnd: _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 +100,268 @@ abstract class MapLibreMapState extends State /// Build the platform specific widget. Widget buildPlatformWidget(BuildContext context); + + void _onDoubleTap() { + debugPrint('Double tap detected'); + + final details = _doubleTapDownDetails; + _doubleTapDownDetails = null; + if (details == null) return; + + if (options.gestures.zoom) { + final camera = this.camera!; + final newCenter = (_targetCamera?.center ?? camera.center) + .intermediatePointTo( + 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, + ), + ); + _animation = tweens.animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + _animationController + ..duration = const Duration(milliseconds: 300) + ..forward(from: 0); + } + } + + 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(); + super.dispose(); + } + + void _onDoubleTapDown(TapDownDetails details) { + // zoom in or out on double tap down + debugPrint('Double tap down at position: ${details.localPosition}'); + _doubleTapDownDetails = details; + } + + void _onDoubleTapCancel() { + debugPrint('Double tap cancelled'); + } + + void _stopAnimation() { + _animationController.stop(); + _animation = null; + _targetCamera = null; + } + + void _scrollWheelZoom(PointerScrollEvent event) { + // debugPrint('Scroll wheel event: ${event.scrollDelta.dy}'); + if (options.gestures.zoom) { + final currCamera = camera!; + final zoomChange = -event.scrollDelta.dy / 300; // sensitivity + final prevTarget = _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( + toLngLat(event.localPosition), + fraction: zoomChange > 0 ? 0.2 : -0.2, + ); + } + final targetCamera = _targetCamera = currCamera.copyWith( + zoom: targetZoom, + center: targetCenter, + ); + final tweens = _MapCameraTween(begin: currCamera, end: targetCamera); + _animation = tweens.animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + _animationController.forward(from: 0); + } + } + + // ignore: use_setters_to_change_properties + void _onScaleStart(ScaleStartDetails details) { + // debugPrint('Scale start: $details'); + _onScaleStartEvent = details; + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + // debugPrint('Scale update: $details'); + final camera = this.camera!; + final startEvent = _onScaleStartEvent; + final pointerDownEvent = _pointerDownEvent; + if (startEvent == null || pointerDownEvent == null) return; + final doubleTapDown = _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(context).platform == TargetPlatform.iOS; + var deltaY = details.focalPoint.dy - lastY; + if (iOS) deltaY = -deltaY; + final newZoom = camera.zoom + deltaY * 0.01; // sensitivity + 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 + } + moveCamera(bearing: newBearing, pitch: newPitch, zoom: newZoom); + } else if ((buttons & kPrimaryMouseButton) != 0) { + // primary button: pan, zoom, bearing, pinch + var pitch = false; + if (options.gestures.pitch && _pointers.length == 2) { + final pointers = _pointers.values.toList(growable: false); + final delta = pointers.first - pointers.last; + pitch = delta.dy.abs() < delta.dx.abs(); + } + + // zoom + var newZoom = camera.zoom; + final lastScale = lastEvent?.scale ?? 1.0; + final scaleDelta = details.scale - lastScale; + if (scaleDelta != 0 && options.gestures.zoom && !pitch) { + const scaleSensitivity = 1.0; + newZoom = camera.zoom + scaleDelta * scaleSensitivity; + } + + // center + var newCenter = camera.center; + if (options.gestures.pan && !pitch) { + final delta = details.focalPoint - lastPointerOffset; + final centerOffset = toScreenLocation(camera.center); + final newCenterOffset = centerOffset - delta; + newCenter = toLngLat(newCenterOffset); + } + + // bearing + var newBearing = camera.bearing; + if (options.gestures.rotate && details.rotation != 0.0 && !pitch) { + final lastRotation = lastEvent?.rotation ?? 0.0; + final rotationDelta = details.rotation - lastRotation; + newBearing = camera.bearing - rotationDelta * radian2Degree; + } + + // pitch + var newPitch = camera.pitch; + if (options.gestures.pitch && pitch) { + final delta = details.focalPoint - lastPointerOffset; + newPitch = camera.pitch - delta.dy * 0.5; // sensitivity; + } + + moveCamera( + zoom: newZoom, + center: newCenter, + bearing: newBearing, + pitch: newPitch, + ); + } + } + + 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) { + var newCenter = camera.center; + if (options.gestures.pan) { + newCenter = toLngLat( + firstEvent.focalPoint, + ).intermediatePointTo(camera.center, fraction: 0.2); + } + 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, + ), + ), + ); + _animation = tweens.animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + _animationController + ..duration = Duration( + milliseconds: (distance / velocity * 1000).round(), + ) + ..value = 0 + ..fling( + velocity: velocity / 1000, + springDescription: SpringDescription.withDampingRatio( + mass: 1, + stiffness: 1000, + ratio: 5, + ), + ); + } + } + + _onScaleStartEvent = null; + _lastScaleUpdateDetails = null; + _secondToLastScaleUpdateDetails = null; + _doubleTapDownDetails = null; + } +} + +class _MapCameraTween extends Tween { + _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/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..f8e81ce7 100644 --- a/maplibre/lib/src/platform/web/map_state.dart +++ b/maplibre/lib/src/platform/web/map_state.dart @@ -52,6 +52,14 @@ final class MapLibreMapStateWeb extends MapLibreMapState { 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, @@ -80,7 +88,7 @@ final class MapLibreMapStateWeb extends MapLibreMapState { _map.setMinPitch(options.minPitch); _map.setMaxPitch(options.maxPitch); _map.setMaxBounds(options.maxBounds?.toJsLngLatBounds()); - _updateGestures(options.gestures); + _updateGestures(const MapGestures.none()); // add callbacks _map.on( @@ -210,7 +218,7 @@ final class MapLibreMapStateWeb extends MapLibreMapState { _map.setMaxBounds(options.maxBounds?.toJsLngLatBounds()); } if (options.gestures != oldWidget.options.gestures) { - _updateGestures(options.gestures); + _updateGestures(const MapGestures.none()); } _layerManager?.updateLayers(widget.layers); super.didUpdateWidget(oldWidget); 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;