From bc82bc4276ad4bd80aa683291ba5867fb5fef880 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Fri, 28 Nov 2025 10:21:02 +0100 Subject: [PATCH 1/3] fix: 2022 - no fling beyond the antimeridian if constrained New file: * `antimeridian.dart`: Testing if we can fling beyond the world. We can't, due to the constraint. Impacted files: * `camera_constraint.dart` * `main.dart`: added new `AntimeridianPage` page. * `map_interactive_viewer.dart` * `menu_drawer.dart` --- example/lib/main.dart | 2 ++ example/lib/pages/antimeridian.dart | 34 ++++++++++++++++++++ example/lib/widgets/drawer/menu_drawer.dart | 6 ++++ lib/src/gestures/map_interactive_viewer.dart | 8 ++++- lib/src/map/camera/camera_constraint.dart | 13 +++++++- 5 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 example/lib/pages/antimeridian.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 969ef5af1..c87399dcb 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_example/pages/abort_obsolete_requests.dart'; import 'package:flutter_map_example/pages/animated_map_controller.dart'; +import 'package:flutter_map_example/pages/antimeridian.dart'; import 'package:flutter_map_example/pages/bundled_offline_map.dart'; import 'package:flutter_map_example/pages/circle.dart'; import 'package:flutter_map_example/pages/debouncing_tile_update_transformer.dart'; @@ -55,6 +56,7 @@ class MyApp extends StatelessWidget { AbortObsoleteRequestsPage.route: (context) => const AbortObsoleteRequestsPage(), PolylinePage.route: (context) => const PolylinePage(), + AntimeridianPage.route: (context) => const AntimeridianPage(), SingleWorldPolysPage.route: (context) => const SingleWorldPolysPage(), PolylinePerfStressPage.route: (context) => const PolylinePerfStressPage(), diff --git a/example/lib/pages/antimeridian.dart b/example/lib/pages/antimeridian.dart new file mode 100644 index 000000000..ecfa579ae --- /dev/null +++ b/example/lib/pages/antimeridian.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/misc/tile_providers.dart'; +import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; +import 'package:latlong2/latlong.dart'; + +/// Testing if we can fling beyond the world. We can't, due to the constraint. +class AntimeridianPage extends StatelessWidget { + static const String route = '/antimeridian'; + + const AntimeridianPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Antimeridian')), + drawer: const MenuDrawer(AntimeridianPage.route), + body: FlutterMap( + options: MapOptions( + initialZoom: 2, + cameraConstraint: CameraConstraint.contain( + bounds: LatLngBounds( + const LatLng(90, -180), + const LatLng(-90, 180), + ), + ), + ), + children: [ + openStreetMapTileLayer, + ], + ), + ); + } +} diff --git a/example/lib/widgets/drawer/menu_drawer.dart b/example/lib/widgets/drawer/menu_drawer.dart index 4107eec51..cb9b6d5e3 100644 --- a/example/lib/widgets/drawer/menu_drawer.dart +++ b/example/lib/widgets/drawer/menu_drawer.dart @@ -5,6 +5,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map_example/pages/abort_obsolete_requests.dart'; import 'package:flutter_map_example/pages/animated_map_controller.dart'; +import 'package:flutter_map_example/pages/antimeridian.dart'; import 'package:flutter_map_example/pages/bundled_offline_map.dart'; import 'package:flutter_map_example/pages/circle.dart'; import 'package:flutter_map_example/pages/debouncing_tile_update_transformer.dart'; @@ -147,6 +148,11 @@ class MenuDrawer extends StatelessWidget { routeName: PolylinePage.route, currentRoute: currentRoute, ), + MenuItemWidget( + caption: 'Antimeridian test', + routeName: AntimeridianPage.route, + currentRoute: currentRoute, + ), MenuItemWidget( caption: 'Circle Layer', routeName: CirclePage.route, diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index ab9f8f07f..e2f79011f 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -114,6 +114,7 @@ class MapInteractiveViewerState extends State late var _keyboardPanAnimationPrevZoom = _camera.zoom; // to detect changes late double _keyboardPanAnimationMaxVelocity; + double _keyboardPanAnimationMaxVelocityCalculator(double zoom) => _interactionOptions.keyboardOptions.maxPanVelocity?.call(zoom) ?? 5 * math.log(0.15 * zoom + 1) + 1; @@ -124,7 +125,9 @@ class MapInteractiveViewerState extends State // Shortcuts MapCamera get _camera => widget.controller.camera; + MapOptions get _options => widget.controller.options; + InteractionOptions get _interactionOptions => _options.interactionOptions; @override @@ -965,12 +968,15 @@ class MapInteractiveViewerState extends State newCenter = _camera.unprojectAtZoom(bestCenterPoint); } - widget.controller.moveRaw( + final moved = widget.controller.moveRaw( newCenter, _camera.zoom, hasGesture: true, source: MapEventSource.flingAnimationController, ); + if (!moved) { + _closeFlingAnimationController(MapEventSource.flingAnimationController); + } } void _resetDoubleTapHold() { diff --git a/lib/src/map/camera/camera_constraint.dart b/lib/src/map/camera/camera_constraint.dart index 423452872..f256edaf8 100644 --- a/lib/src/map/camera/camera_constraint.dart +++ b/lib/src/map/camera/camera_constraint.dart @@ -142,7 +142,18 @@ class ContainCamera extends CameraConstraint { centerPix.dy.clamp(topOkCenter, botOkCenter), ); - if (newCenterPix == centerPix) return camera; + if (newCenterPix == centerPix) { + return camera; + } + + // Special case when the world is the limit: we want to stop at + // antimeridian lines. + // The standard test cannot work, as we'll always be in [-180,180]. + // If the center changed, that means that the clamp did have an action. + // Therefore we went beyond the world. + if (bounds.west == -180 && bounds.east == 180) { + return null; + } return camera.withPosition( center: camera.unprojectAtZoom(newCenterPix, testZoom), From ce07cd5baaa9e853ee24dc5d24352417ba383f63 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Fri, 28 Nov 2025 11:50:34 +0100 Subject: [PATCH 2/3] fine-tuning after failing tests --- lib/src/map/camera/camera.dart | 6 ++++ lib/src/map/camera/camera_constraint.dart | 35 ++++++++----------- .../map/controller/map_controller_impl.dart | 10 ++++++ test/map/camera/camera_constraint_test.dart | 4 +++ 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index c9c6d980a..b68c2c4d0 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -44,6 +44,9 @@ class MapCamera { /// FlutterMap widget. final Size nonRotatedSize; + /// Is it the result of a constraint? + final bool constrained; + /// Lazily calculated field Size? _cameraSize; @@ -136,6 +139,7 @@ class MapCamera { required this.nonRotatedSize, this.minZoom, this.maxZoom, + this.constrained = false, Size? size, Rect? pixelBounds, LatLngBounds? bounds, @@ -218,6 +222,7 @@ class MapCamera { MapCamera withPosition({ LatLng? center, double? zoom, + bool constrained = false, }) => MapCamera( crs: crs, @@ -228,6 +233,7 @@ class MapCamera { rotation: rotation, nonRotatedSize: nonRotatedSize, size: _cameraSize, + constrained: constrained, ); /// Jumps camera to opposite side of the world to enable seamless scrolling diff --git a/lib/src/map/camera/camera_constraint.dart b/lib/src/map/camera/camera_constraint.dart index f256edaf8..75297f0a4 100644 --- a/lib/src/map/camera/camera_constraint.dart +++ b/lib/src/map/camera/camera_constraint.dart @@ -79,18 +79,18 @@ class ContainCameraCenter extends CameraConstraint { final LatLngBounds bounds; @override - MapCamera constrain(MapCamera camera) => camera.withPosition( - center: LatLng( - camera.center.latitude.clamp( - bounds.south, - bounds.north, - ), - camera.center.longitude.clamp( - bounds.west, - bounds.east, - ), - ), - ); + MapCamera constrain(MapCamera camera) { + final latitude = camera.center.latitude.clamp(bounds.south, bounds.north); + final longitude = camera.center.longitude.clamp(bounds.west, bounds.east); + if (latitude == camera.center.latitude && + longitude == camera.center.longitude) { + return camera; + } + return camera.withPosition( + center: LatLng(latitude, longitude), + constrained: true, + ); + } @override bool operator ==(Object other) { @@ -146,17 +146,9 @@ class ContainCamera extends CameraConstraint { return camera; } - // Special case when the world is the limit: we want to stop at - // antimeridian lines. - // The standard test cannot work, as we'll always be in [-180,180]. - // If the center changed, that means that the clamp did have an action. - // Therefore we went beyond the world. - if (bounds.west == -180 && bounds.east == 180) { - return null; - } - return camera.withPosition( center: camera.unprojectAtZoom(newCenterPix, testZoom), + constrained: true, ); } @@ -222,6 +214,7 @@ class ContainCameraLatitude extends CameraConstraint { return camera.withPosition( center: camera.unprojectAtZoom(newCenterPix, testZoom), + constrained: true, ); } diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index 0ed0ed9b9..3caa70154 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -170,6 +170,16 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> (newCamera.center == camera.center && newCamera.zoom == camera.zoom)) { return false; } + if (newCamera.constrained) { + if (options.cameraConstraint is ContainCamera) { + final ContainCamera containCamera = + options.cameraConstraint as ContainCamera; + if (containCamera.bounds.west == -180 && + containCamera.bounds.east == 180) { + return false; + } + } + } final oldCamera = camera; value = value.withMapCamera(newCamera); diff --git a/test/map/camera/camera_constraint_test.dart b/test/map/camera/camera_constraint_test.dart index 73d9e60a7..54c1a54f3 100644 --- a/test/map/camera/camera_constraint_test.dart +++ b/test/map/camera/camera_constraint_test.dart @@ -28,6 +28,7 @@ void main() { expect(clamped.zoom, 1); expect(clamped.center.latitude, closeTo(-48.562, 0.001)); expect(clamped.center.longitude, closeTo(-55.703, 0.001)); + expect(clamped.constrained, isTrue); }); }); @@ -48,6 +49,7 @@ void main() { expect(clamped.zoom, 1); expect(clamped.center.latitude, closeTo(0, 0.001)); expect(clamped.center.longitude, closeTo(-179.9, 0.001)); + expect(clamped.constrained, isFalse); }); }); @@ -67,6 +69,7 @@ void main() { expect(clamped.zoom, 1); expect(clamped.center.latitude, closeTo(-59.534, 0.001)); expect(clamped.center.longitude, closeTo(179, 0.001)); + expect(clamped.constrained, isFalse); }); test('northern hemisphere', () { @@ -85,6 +88,7 @@ void main() { expect(clamped.zoom, 2); expect(clamped.center.latitude, closeTo(46.558, 0.001)); expect(clamped.center.longitude, closeTo(179, 0.001)); + expect(clamped.constrained, isFalse); }); test('can not translate camera within bounds', () { From fac29dfb0456ab4438b06003464b93ea6c0aaced Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Fri, 28 Nov 2025 11:57:11 +0100 Subject: [PATCH 3/3] fine-tuning after failing tests --- test/map/camera/camera_constraint_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/map/camera/camera_constraint_test.dart b/test/map/camera/camera_constraint_test.dart index 54c1a54f3..7bad712c5 100644 --- a/test/map/camera/camera_constraint_test.dart +++ b/test/map/camera/camera_constraint_test.dart @@ -69,7 +69,7 @@ void main() { expect(clamped.zoom, 1); expect(clamped.center.latitude, closeTo(-59.534, 0.001)); expect(clamped.center.longitude, closeTo(179, 0.001)); - expect(clamped.constrained, isFalse); + expect(clamped.constrained, isTrue); }); test('northern hemisphere', () { @@ -88,7 +88,7 @@ void main() { expect(clamped.zoom, 2); expect(clamped.center.latitude, closeTo(46.558, 0.001)); expect(clamped.center.longitude, closeTo(179, 0.001)); - expect(clamped.constrained, isFalse); + expect(clamped.constrained, isTrue); }); test('can not translate camera within bounds', () {