diff --git a/analysis_options.yaml b/analysis_options.yaml index 58bbcff6c..d5adb5dc8 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -16,6 +16,7 @@ linter: avoid_dynamic_calls: true cancel_subscriptions: true close_sinks: true + directives_ordering: true package_api_docs: true prefer_constructors_over_static_methods: true prefer_final_in_for_each: true @@ -25,4 +26,4 @@ linter: throw_in_finally: true type_annotate_public_apis: true unnecessary_statements: true - use_named_constants: true \ No newline at end of file + use_named_constants: true diff --git a/example/lib/main.dart b/example/lib/main.dart index fbc18d44c..c2ad69bd1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:flutter_map_example/pages/animated_map_controller.dart'; import 'package:flutter_map_example/pages/circle.dart'; import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart'; diff --git a/example/lib/pages/animated_map_controller.dart b/example/lib/pages/animated_map_controller.dart index 0f0b1529f..8aeb6a15d 100644 --- a/example/lib/pages/animated_map_controller.dart +++ b/example/lib/pages/animated_map_controller.dart @@ -35,11 +35,12 @@ class AnimatedMapControllerPageState extends State void _animatedMapMove(LatLng destLocation, double destZoom) { // Create some tweens. These serve to split up the transition from one location to another. // In our case, we want to split the transition be our current map center and the destination. + final camera = mapController.camera; final latTween = Tween( - begin: mapController.center.latitude, end: destLocation.latitude); + begin: camera.center.latitude, end: destLocation.latitude); final lngTween = Tween( - begin: mapController.center.longitude, end: destLocation.longitude); - final zoomTween = Tween(begin: mapController.zoom, end: destZoom); + begin: camera.center.longitude, end: destLocation.longitude); + final zoomTween = Tween(begin: camera.zoom, end: destZoom); // Create a animation controller that has a duration and a TickerProvider. final controller = AnimationController( @@ -161,10 +162,10 @@ class AnimatedMapControllerPageState extends State london, ]); - mapController.fitBounds( - bounds, - options: const FitBoundsOptions( - padding: EdgeInsets.only(left: 15, right: 15), + mapController.fitCamera( + CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.symmetric(horizontal: 15), ), ); }, @@ -178,9 +179,10 @@ class AnimatedMapControllerPageState extends State london, ]); - final centerZoom = - mapController.centerZoomFitBounds(bounds); - _animatedMapMove(centerZoom.center, centerZoom.zoom); + final constrained = CameraFit.bounds( + bounds: bounds, + ).fit(mapController.camera); + _animatedMapMove(constrained.center, constrained.zoom); }, child: const Text('Fit Bounds animated'), ), @@ -190,9 +192,9 @@ class AnimatedMapControllerPageState extends State Flexible( child: FlutterMap( mapController: mapController, - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, maxZoom: 10, minZoom: 3), children: [ diff --git a/example/lib/pages/circle.dart b/example/lib/pages/circle.dart index f3a332241..88cc3f8e7 100644 --- a/example/lib/pages/circle.dart +++ b/example/lib/pages/circle.dart @@ -33,9 +33,9 @@ class CirclePage extends StatelessWidget { ), Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 11, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 11, ), children: [ TileLayer( diff --git a/example/lib/pages/custom_crs/custom_crs.dart b/example/lib/pages/custom_crs/custom_crs.dart index 83d887747..3022f32d2 100644 --- a/example/lib/pages/custom_crs/custom_crs.dart +++ b/example/lib/pages/custom_crs/custom_crs.dart @@ -128,8 +128,8 @@ class _CustomCrsPageState extends State { options: MapOptions( // Set the default CRS crs: epsg3413CRS, - center: LatLng(point.x, point.y), - zoom: 3, + initialCenter: LatLng(point.x, point.y), + initialZoom: 3, // Set maxZoom usually scales.length - 1 OR resolutions.length - 1 // but not greater maxZoom: maxZoom, diff --git a/example/lib/pages/epsg3413_crs.dart b/example/lib/pages/epsg3413_crs.dart index 3d7d4affd..11d9f4636 100644 --- a/example/lib/pages/epsg3413_crs.dart +++ b/example/lib/pages/epsg3413_crs.dart @@ -130,8 +130,8 @@ class _EPSG3413PageState extends State { child: FlutterMap( options: MapOptions( crs: epsg3413CRS, - center: const LatLng(90, 0), - zoom: 3, + initialCenter: const LatLng(90, 0), + initialZoom: 3, maxZoom: maxZoom, ), nonRotatedChildren: [ diff --git a/example/lib/pages/epsg4326_crs.dart b/example/lib/pages/epsg4326_crs.dart index 544967441..0bc393905 100644 --- a/example/lib/pages/epsg4326_crs.dart +++ b/example/lib/pages/epsg4326_crs.dart @@ -23,11 +23,11 @@ class EPSG4326Page extends StatelessWidget { ), Flexible( child: FlutterMap( - options: MapOptions( + options: const MapOptions( minZoom: 0, - crs: const Epsg4326(), - center: const LatLng(0, 0), - zoom: 0, + crs: Epsg4326(), + initialCenter: LatLng(0, 0), + initialZoom: 0, ), children: [ TileLayer( @@ -37,7 +37,7 @@ class EPSG4326Page extends StatelessWidget { layers: ['TOPO-OSM-WMS'], ), userAgentPackageName: 'dev.fleaflet.flutter_map.example', - ) + ), ], ), ), diff --git a/example/lib/pages/fallback_url.dart b/example/lib/pages/fallback_url.dart index 619de1e60..11fd6e8e9 100644 --- a/example/lib/pages/fallback_url.dart +++ b/example/lib/pages/fallback_url.dart @@ -41,8 +41,8 @@ class FallbackUrlPage extends StatelessWidget { Flexible( child: FlutterMap( options: MapOptions( - center: center, - zoom: zoom, + initialCenter: center, + initialZoom: zoom, maxZoom: maxZoom, minZoom: minZoom, ), diff --git a/example/lib/pages/home.dart b/example/lib/pages/home.dart index 794d8f61d..6015db39a 100644 --- a/example/lib/pages/home.dart +++ b/example/lib/pages/home.dart @@ -112,11 +112,13 @@ class _HomePageState extends State { Flexible( child: FlutterMap( options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, - maxBounds: LatLngBounds( - const LatLng(-90, -180), - const LatLng(90, 180), + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 5, + cameraConstraint: CameraConstraint.contain( + bounds: LatLngBounds( + const LatLng(-90, -180), + const LatLng(90, 180), + ), ), ), nonRotatedChildren: [ diff --git a/example/lib/pages/interactive_test_page.dart b/example/lib/pages/interactive_test_page.dart index 9a8283236..883b2af73 100644 --- a/example/lib/pages/interactive_test_page.dart +++ b/example/lib/pages/interactive_test_page.dart @@ -28,7 +28,7 @@ class _InteractiveTestPageState extends State { void onMapEvent(MapEvent mapEvent) { if (mapEvent is! MapEventMove && mapEvent is! MapEventRotate) { // do not flood console with move and rotate events - debugPrint(mapEvent.toString()); + debugPrint(_eventName(mapEvent)); } setState(() { @@ -59,7 +59,7 @@ class _InteractiveTestPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ MaterialButton( - color: InteractiveFlag.hasFlag(flags, InteractiveFlag.drag) + color: InteractiveFlag.hasDrag(flags) ? Colors.greenAccent : Colors.redAccent, onPressed: () { @@ -70,8 +70,7 @@ class _InteractiveTestPageState extends State { child: const Text('Drag'), ), MaterialButton( - color: InteractiveFlag.hasFlag( - flags, InteractiveFlag.flingAnimation) + color: InteractiveFlag.hasFlingAnimation(flags) ? Colors.greenAccent : Colors.redAccent, onPressed: () { @@ -82,10 +81,9 @@ class _InteractiveTestPageState extends State { child: const Text('Fling'), ), MaterialButton( - color: - InteractiveFlag.hasFlag(flags, InteractiveFlag.pinchMove) - ? Colors.greenAccent - : Colors.redAccent, + color: InteractiveFlag.hasPinchMove(flags) + ? Colors.greenAccent + : Colors.redAccent, onPressed: () { setState(() { updateFlags(InteractiveFlag.pinchMove); @@ -99,8 +97,7 @@ class _InteractiveTestPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ MaterialButton( - color: InteractiveFlag.hasFlag( - flags, InteractiveFlag.doubleTapZoom) + color: InteractiveFlag.hasDoubleTapZoom(flags) ? Colors.greenAccent : Colors.redAccent, onPressed: () { @@ -111,7 +108,7 @@ class _InteractiveTestPageState extends State { child: const Text('Double tap zoom'), ), MaterialButton( - color: InteractiveFlag.hasFlag(flags, InteractiveFlag.rotate) + color: InteractiveFlag.hasRotate(flags) ? Colors.greenAccent : Colors.redAccent, onPressed: () { @@ -122,10 +119,9 @@ class _InteractiveTestPageState extends State { child: const Text('Rotate'), ), MaterialButton( - color: - InteractiveFlag.hasFlag(flags, InteractiveFlag.pinchZoom) - ? Colors.greenAccent - : Colors.redAccent, + color: InteractiveFlag.hasPinchZoom(flags) + ? Colors.greenAccent + : Colors.redAccent, onPressed: () { setState(() { updateFlags(InteractiveFlag.pinchZoom); @@ -139,7 +135,7 @@ class _InteractiveTestPageState extends State { padding: const EdgeInsets.only(top: 8, bottom: 8), child: Center( child: Text( - 'Current event: ${_latestEvent?.runtimeType ?? "none"}\nSource: ${_latestEvent?.source ?? "none"}', + 'Current event: ${_eventName(_latestEvent)}\nSource: ${_latestEvent?.source.name ?? "none"}', textAlign: TextAlign.center, ), ), @@ -148,9 +144,11 @@ class _InteractiveTestPageState extends State { child: FlutterMap( options: MapOptions( onMapEvent: onMapEvent, - center: const LatLng(51.5, -0.09), - zoom: 11, - interactiveFlags: flags, + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 11, + interactionOptions: InteractionOptions( + flags: flags, + ), ), children: [ TileLayer( @@ -166,4 +164,49 @@ class _InteractiveTestPageState extends State { ), ); } + + String _eventName(MapEvent? event) { + switch (event) { + case MapEventTap(): + return 'MapEventTap'; + case MapEventSecondaryTap(): + return 'MapEventSecondaryTap'; + case MapEventLongPress(): + return 'MapEventLongPress'; + case MapEventMove(): + return 'MapEventMove'; + case MapEventMoveStart(): + return 'MapEventMoveStart'; + case MapEventMoveEnd(): + return 'MapEventMoveEnd'; + case MapEventFlingAnimation(): + return 'MapEventFlingAnimation'; + case MapEventFlingAnimationNotStarted(): + return 'MapEventFlingAnimationNotStarted'; + case MapEventFlingAnimationStart(): + return 'MapEventFlingAnimationStart'; + case MapEventFlingAnimationEnd(): + return 'MapEventFlingAnimationEnd'; + case MapEventDoubleTapZoom(): + return 'MapEventDoubleTapZoom'; + case MapEventScrollWheelZoom(): + return 'MapEventScrollWheelZoom'; + case MapEventDoubleTapZoomStart(): + return 'MapEventDoubleTapZoomStart'; + case MapEventDoubleTapZoomEnd(): + return 'MapEventDoubleTapZoomEnd'; + case MapEventRotate(): + return 'MapEventRotate'; + case MapEventRotateStart(): + return 'MapEventRotateStart'; + case MapEventRotateEnd(): + return 'MapEventRotateEnd'; + case MapEventNonRotatedSizeChange(): + return 'MapEventNonRotatedSizeChange'; + case null: + return 'null'; + default: + return 'Unknown'; + } + } } diff --git a/example/lib/pages/latlng_to_screen_point.dart b/example/lib/pages/latlng_to_screen_point.dart index 7e6497576..db629de91 100644 --- a/example/lib/pages/latlng_to_screen_point.dart +++ b/example/lib/pages/latlng_to_screen_point.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_example/widgets/drawer.dart'; import 'package:flutter_map/plugin_api.dart'; +import 'package:flutter_map_example/widgets/drawer.dart'; import 'package:latlong2/latlong.dart'; class LatLngScreenPointTestPage extends StatefulWidget { @@ -36,9 +36,10 @@ class _LatLngScreenPointTestPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('LatLng To Screen Point')), - drawer: buildDrawer(context, LatLngScreenPointTestPage.route), - body: Stack(children: [ + appBar: AppBar(title: const Text('LatLng To Screen Point')), + drawer: buildDrawer(context, LatLngScreenPointTestPage.route), + body: Stack( + children: [ Padding( padding: const EdgeInsets.all(8), child: FlutterMap( @@ -46,13 +47,13 @@ class _LatLngScreenPointTestPageState extends State { options: MapOptions( onMapEvent: onMapEvent, onTap: (tapPos, latLng) { - final pt1 = _mapController.latLngToScreenPoint(latLng); + final pt1 = _mapController.camera.latLngToScreenPoint(latLng); _textPos = CustomPoint(pt1.x, pt1.y); setState(() {}); }, - center: const LatLng(51.5, -0.09), - zoom: 11, - rotation: 0, + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 11, + initialRotation: 0, ), children: [ TileLayer( @@ -68,6 +69,8 @@ class _LatLngScreenPointTestPageState extends State { width: 20, height: 20, child: const FlutterLogo()) - ])); + ], + ), + ); } } diff --git a/example/lib/pages/many_markers.dart b/example/lib/pages/many_markers.dart index b0ad1bf42..55817f747 100644 --- a/example/lib/pages/many_markers.dart +++ b/example/lib/pages/many_markers.dart @@ -72,10 +72,12 @@ class _ManyMarkersPageState extends State { Text('$_sliderVal markers'), Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(50, 20), - zoom: 5, - interactiveFlags: InteractiveFlag.all - InteractiveFlag.rotate, + options: const MapOptions( + initialCenter: LatLng(50, 20), + initialZoom: 5, + interactionOptions: InteractionOptions( + flags: InteractiveFlag.all - InteractiveFlag.rotate, + ), ), children: [ TileLayer( diff --git a/example/lib/pages/map_controller.dart b/example/lib/pages/map_controller.dart index 1aa7086ff..6d173a4c7 100644 --- a/example/lib/pages/map_controller.dart +++ b/example/lib/pages/map_controller.dart @@ -104,10 +104,10 @@ class MapControllerPageState extends State { london, ]); - _mapController.fitBounds( - bounds, - options: const FitBoundsOptions( - padding: EdgeInsets.only(left: 15, right: 15), + _mapController.fitCamera( + CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.symmetric(horizontal: 15), ), ); }, @@ -116,7 +116,7 @@ class MapControllerPageState extends State { Builder(builder: (BuildContext context) { return MaterialButton( onPressed: () { - final bounds = _mapController.bounds!; + final bounds = _mapController.camera.visibleBounds; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text( @@ -151,9 +151,9 @@ class MapControllerPageState extends State { Flexible( child: FlutterMap( mapController: _mapController, - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, maxZoom: 5, minZoom: 3, ), diff --git a/example/lib/pages/map_inside_listview.dart b/example/lib/pages/map_inside_listview.dart index 50734e64a..edc4df362 100644 --- a/example/lib/pages/map_inside_listview.dart +++ b/example/lib/pages/map_inside_listview.dart @@ -22,9 +22,9 @@ class MapInsideListViewPage extends StatelessWidget { SizedBox( height: 300, child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, ), nonRotatedChildren: const [ FlutterMapZoomButtons( diff --git a/example/lib/pages/markers.dart b/example/lib/pages/markers.dart index a0f417c97..6c12ecaa0 100644 --- a/example/lib/pages/markers.dart +++ b/example/lib/pages/markers.dart @@ -104,11 +104,13 @@ class MarkerPageState extends State { Flexible( child: FlutterMap( options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 5, onTap: (_, p) => setState(() => customMarkers.add(buildPin(p))), - interactiveFlags: ~InteractiveFlag.doubleTapZoom, + interactionOptions: const InteractionOptions( + flags: ~InteractiveFlag.doubleTapZoom, + ), ), children: [ TileLayer( diff --git a/example/lib/pages/moving_markers.dart b/example/lib/pages/moving_markers.dart index 6b8d75efe..02b984034 100644 --- a/example/lib/pages/moving_markers.dart +++ b/example/lib/pages/moving_markers.dart @@ -54,9 +54,9 @@ class _MovingMarkersPageState extends State { ), Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, ), children: [ TileLayer( diff --git a/example/lib/pages/offline_map.dart b/example/lib/pages/offline_map.dart index 91c9ca41b..89d6e5a2b 100644 --- a/example/lib/pages/offline_map.dart +++ b/example/lib/pages/offline_map.dart @@ -25,11 +25,15 @@ class OfflineMapPage extends StatelessWidget { Flexible( child: FlutterMap( options: MapOptions( - center: const LatLng(56.704173, 11.543808), + initialCenter: const LatLng(56.704173, 11.543808), minZoom: 12, maxZoom: 14, - swPanBoundary: const LatLng(56.6877, 11.5089), - nePanBoundary: const LatLng(56.7378, 11.6644), + cameraConstraint: CameraConstraint.containCenter( + bounds: LatLngBounds( + const LatLng(56.7378, 11.6644), + const LatLng(56.6877, 11.5089), + ), + ), ), children: [ TileLayer( diff --git a/example/lib/pages/overlay_image.dart b/example/lib/pages/overlay_image.dart index 2fb86489b..f3310238f 100644 --- a/example/lib/pages/overlay_image.dart +++ b/example/lib/pages/overlay_image.dart @@ -45,9 +45,9 @@ class OverlayImagePage extends StatelessWidget { ), Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 6, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 6, ), children: [ TileLayer( diff --git a/example/lib/pages/plugin_scalebar.dart b/example/lib/pages/plugin_scalebar.dart index edbe74049..f0fc28596 100644 --- a/example/lib/pages/plugin_scalebar.dart +++ b/example/lib/pages/plugin_scalebar.dart @@ -20,19 +20,20 @@ class PluginScaleBar extends StatelessWidget { children: [ Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, ), nonRotatedChildren: [ ScaleLayerWidget( - options: ScaleLayerPluginOption( - lineColor: Colors.blue, - lineWidth: 2, - textStyle: - const TextStyle(color: Colors.blue, fontSize: 12), - padding: const EdgeInsets.all(10), - )), + options: ScaleLayerPluginOption( + lineColor: Colors.blue, + lineWidth: 2, + textStyle: + const TextStyle(color: Colors.blue, fontSize: 12), + padding: const EdgeInsets.all(10), + ), + ), ], children: [ TileLayer( diff --git a/example/lib/pages/plugin_zoombuttons.dart b/example/lib/pages/plugin_zoombuttons.dart index 3c95f1fe2..ca24acb3c 100644 --- a/example/lib/pages/plugin_zoombuttons.dart +++ b/example/lib/pages/plugin_zoombuttons.dart @@ -20,26 +20,27 @@ class PluginZoomButtons extends StatelessWidget { children: [ Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, + ), + nonRotatedChildren: const [ + FlutterMapZoomButtons( + minZoom: 4, + maxZoom: 19, + mini: true, + padding: 10, + alignment: Alignment.bottomRight, ), - nonRotatedChildren: const [ - FlutterMapZoomButtons( - minZoom: 4, - maxZoom: 19, - mini: true, - padding: 10, - alignment: Alignment.bottomRight, - ), - ], - children: [ - TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'dev.fleaflet.flutter_map.example', - ), - ]), + ], + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'dev.fleaflet.flutter_map.example', + ) + ], + ), ), ], ), diff --git a/example/lib/pages/point_to_latlng.dart b/example/lib/pages/point_to_latlng.dart index c00c62e0e..e0ab2c55f 100644 --- a/example/lib/pages/point_to_latlng.dart +++ b/example/lib/pages/point_to_latlng.dart @@ -59,8 +59,8 @@ class PointToLatlngPage extends State { onMapEvent: (event) { updatePoint(null, context); }, - center: const LatLng(51.5, -0.09), - zoom: 5, + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 5, minZoom: 3, ), children: [ @@ -78,7 +78,7 @@ class PointToLatlngPage extends State { builder: (ctx) => const FlutterLogo(), ) ], - ) + ), ], ), Container( @@ -106,7 +106,7 @@ class PointToLatlngPage extends State { void updatePoint(MapEvent? event, BuildContext context) { final pointX = _getPointX(context); setState(() { - latLng = mapController.pointToLatLng(CustomPoint(pointX, pointY)); + latLng = mapController.camera.pointToLatLng(CustomPoint(pointX, pointY)); }); } diff --git a/example/lib/pages/polygon.dart b/example/lib/pages/polygon.dart index 9bffd1423..7667e39e1 100644 --- a/example/lib/pages/polygon.dart +++ b/example/lib/pages/polygon.dart @@ -77,9 +77,9 @@ class PolygonPage extends StatelessWidget { ), Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, ), children: [ TileLayer( diff --git a/example/lib/pages/polyline.dart b/example/lib/pages/polyline.dart index 0fb9ee465..ce6e170e9 100644 --- a/example/lib/pages/polyline.dart +++ b/example/lib/pages/polyline.dart @@ -28,9 +28,9 @@ class _PolylinePageState extends State { ), Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, ), children: [ TileLayer( diff --git a/example/lib/pages/reset_tile_layer.dart b/example/lib/pages/reset_tile_layer.dart index 9c9e35528..8393a8409 100644 --- a/example/lib/pages/reset_tile_layer.dart +++ b/example/lib/pages/reset_tile_layer.dart @@ -71,9 +71,9 @@ class ResetTileLayerPageState extends State { ), Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, ), children: [ TileLayer( diff --git a/example/lib/pages/scale_layer_plugin_option.dart b/example/lib/pages/scale_layer_plugin_option.dart index ba760efd8..6bb64d146 100644 --- a/example/lib/pages/scale_layer_plugin_option.dart +++ b/example/lib/pages/scale_layer_plugin_option.dart @@ -3,7 +3,6 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_map/plugin_api.dart'; - import 'package:flutter_map_example/pages/scalebar_utils.dart' as util; class ScaleLayerPluginOption { @@ -52,7 +51,7 @@ class ScaleLayerWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final map = FlutterMapState.of(context); + final map = MapCamera.of(context); final zoom = map.zoom; final distance = scale[max(0, min(20, zoom.round() + 2))].toDouble(); final center = map.center; diff --git a/example/lib/pages/secondary_tap.dart b/example/lib/pages/secondary_tap.dart index 6a82a66f9..f0a9273d7 100644 --- a/example/lib/pages/secondary_tap.dart +++ b/example/lib/pages/secondary_tap.dart @@ -29,8 +29,8 @@ class SecondaryTapPage extends StatelessWidget { SnackBar(content: Text('Secondary tap at $latLng')), ); }, - center: const LatLng(51.5, -0.09), - zoom: 5, + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 5, ), children: [ TileLayer( diff --git a/example/lib/pages/sliding_map.dart b/example/lib/pages/sliding_map.dart index 09bf7eb77..69ff0d870 100644 --- a/example/lib/pages/sliding_map.dart +++ b/example/lib/pages/sliding_map.dart @@ -5,6 +5,8 @@ import 'package:latlong2/latlong.dart'; class SlidingMapPage extends StatelessWidget { static const String route = '/sliding_map'; + static const northEast = LatLng(56.7378, 11.6644); + static const southWest = LatLng(56.6877, 11.5089); const SlidingMapPage({Key? key}) : super(key: key); @@ -25,14 +27,13 @@ class SlidingMapPage extends StatelessWidget { Flexible( child: FlutterMap( options: MapOptions( - center: const LatLng(56.704173, 11.543808), + initialCenter: const LatLng(56.704173, 11.543808), minZoom: 12, maxZoom: 14, - zoom: 13, - swPanBoundary: const LatLng(56.6877, 11.5089), - nePanBoundary: const LatLng(56.7378, 11.6644), - slideOnBoundaries: true, - screenSize: MediaQuery.of(context).size, + initialZoom: 13, + cameraConstraint: CameraConstraint.containCenter( + bounds: LatLngBounds(northEast, southWest), + ), ), children: [ TileLayer( diff --git a/example/lib/pages/stateful_markers.dart b/example/lib/pages/stateful_markers.dart index 4b93f7f69..1b5468628 100644 --- a/example/lib/pages/stateful_markers.dart +++ b/example/lib/pages/stateful_markers.dart @@ -59,9 +59,9 @@ class _StatefulMarkersPageState extends State { ), Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, ), children: [ TileLayer( diff --git a/example/lib/pages/tile_builder_example.dart b/example/lib/pages/tile_builder_example.dart index d080f513d..cad94dec5 100644 --- a/example/lib/pages/tile_builder_example.dart +++ b/example/lib/pages/tile_builder_example.dart @@ -120,9 +120,9 @@ class _TileBuilderPageState extends State { body: Padding( padding: const EdgeInsets.all(8), child: FlutterMap( - options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, ), children: [ _darkModeContainerIfEnabled( diff --git a/example/lib/pages/tile_loading_error_handle.dart b/example/lib/pages/tile_loading_error_handle.dart index 5c1f55cd4..64de27f77 100644 --- a/example/lib/pages/tile_loading_error_handle.dart +++ b/example/lib/pages/tile_loading_error_handle.dart @@ -32,8 +32,8 @@ class _TileLoadingErrorHandleState extends State { child: Builder(builder: (BuildContext context) { return FlutterMap( options: MapOptions( - center: const LatLng(51.5, -0.09), - zoom: 5, + initialCenter: const LatLng(51.5, -0.09), + initialZoom: 5, onPositionChanged: (MapPosition mapPosition, bool _) { needLoadingError = true; }, diff --git a/example/lib/pages/wms_tile_layer.dart b/example/lib/pages/wms_tile_layer.dart index 1459e3598..4e90e8100 100644 --- a/example/lib/pages/wms_tile_layer.dart +++ b/example/lib/pages/wms_tile_layer.dart @@ -24,9 +24,9 @@ class WMSLayerPage extends StatelessWidget { ), Flexible( child: FlutterMap( - options: MapOptions( - center: const LatLng(42.58, 12.43), - zoom: 6, + options: const MapOptions( + initialCenter: LatLng(42.58, 12.43), + initialZoom: 6, ), nonRotatedChildren: [ RichAttributionWidget( @@ -58,7 +58,7 @@ class WMSLayerPage extends StatelessWidget { ), subdomains: const ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], userAgentPackageName: 'dev.fleaflet.flutter_map.example', - ) + ), ], ), ), diff --git a/example/lib/pages/zoombuttons_plugin_option.dart b/example/lib/pages/zoombuttons_plugin_option.dart index 1bb102fdc..fff8d099f 100644 --- a/example/lib/pages/zoombuttons_plugin_option.dart +++ b/example/lib/pages/zoombuttons_plugin_option.dart @@ -14,8 +14,7 @@ class FlutterMapZoomButtons extends StatelessWidget { final IconData zoomInIcon; final IconData zoomOutIcon; - final FitBoundsOptions options = - const FitBoundsOptions(padding: EdgeInsets.all(12)); + static const _fitBoundsPadding = EdgeInsets.all(12); const FlutterMapZoomButtons({ super.key, @@ -34,7 +33,7 @@ class FlutterMapZoomButtons extends StatelessWidget { @override Widget build(BuildContext context) { - final map = FlutterMapState.of(context); + final map = MapCamera.of(context); return Align( alignment: alignment, child: Column( @@ -48,14 +47,15 @@ class FlutterMapZoomButtons extends StatelessWidget { mini: mini, backgroundColor: zoomInColor ?? Theme.of(context).primaryColor, onPressed: () { - final bounds = map.bounds; - final centerZoom = map.getBoundsCenterZoom(bounds, options); - var zoom = centerZoom.zoom + 1; + final paddedMapCamera = CameraFit.bounds( + bounds: map.visibleBounds, + padding: _fitBoundsPadding, + ).fit(map); + var zoom = paddedMapCamera.zoom + 1; if (zoom > maxZoom) { zoom = maxZoom; } - map.move(centerZoom.center, zoom, - source: MapEventSource.custom); + MapController.of(context).move(paddedMapCamera.center, zoom); }, child: Icon(zoomInIcon, color: zoomInColorIcon ?? IconTheme.of(context).color), @@ -68,14 +68,15 @@ class FlutterMapZoomButtons extends StatelessWidget { mini: mini, backgroundColor: zoomOutColor ?? Theme.of(context).primaryColor, onPressed: () { - final bounds = map.bounds; - final centerZoom = map.getBoundsCenterZoom(bounds, options); - var zoom = centerZoom.zoom - 1; + final paddedMapCamera = CameraFit.bounds( + bounds: map.visibleBounds, + padding: _fitBoundsPadding, + ).fit(map); + var zoom = paddedMapCamera.zoom - 1; if (zoom < minZoom) { zoom = minZoom; } - map.move(centerZoom.center, zoom, - source: MapEventSource.custom); + MapController.of(context).move(paddedMapCamera.center, zoom); }, child: Icon(zoomOutIcon, color: zoomOutColorIcon ?? IconTheme.of(context).color), diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 057e8f8a6..997e35da2 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,10 @@ import FlutterMacOS import Foundation -import location import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - LocationPlugin.register(with: registry.registrar(forPlugin: "LocationPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 038466ee6..99f6eeab0 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -1,11 +1,5 @@ library flutter_map; -export 'package:flutter_map/src/misc/center_zoom.dart'; -export 'package:flutter_map/src/misc/fit_bounds_options.dart'; -export 'package:flutter_map/src/misc/move_and_rotate_result.dart'; -export 'package:flutter_map/src/misc/point.dart'; -export 'package:flutter_map/src/misc/position.dart'; -export 'package:flutter_map/src/misc/private/positioned_tap_detector_2.dart'; export 'package:flutter_map/src/geo/crs.dart'; export 'package:flutter_map/src/geo/latlng_bounds.dart'; export 'package:flutter_map/src/gestures/interactive_flag.dart'; @@ -32,6 +26,15 @@ export 'package:flutter_map/src/layer/tile_layer/tile_provider/file_providers/ti export 'package:flutter_map/src/layer/tile_layer/tile_provider/network_tile_provider.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; -export 'package:flutter_map/src/map/controller.dart' hide MapControllerImpl; -export 'package:flutter_map/src/map/widget.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/map_controller.dart'; export 'package:flutter_map/src/map/options.dart'; +export 'package:flutter_map/src/map/widget.dart'; +export 'package:flutter_map/src/misc/center_zoom.dart'; +export 'package:flutter_map/src/misc/fit_bounds_options.dart'; +export 'package:flutter_map/src/misc/move_and_rotate_result.dart'; +export 'package:flutter_map/src/misc/point.dart'; +export 'package:flutter_map/src/misc/position.dart'; +export 'package:flutter_map/src/misc/private/positioned_tap_detector_2.dart'; diff --git a/lib/plugin_api.dart b/lib/plugin_api.dart index cecfbbb40..628c08fde 100644 --- a/lib/plugin_api.dart +++ b/lib/plugin_api.dart @@ -1,9 +1,5 @@ library flutter_map.plugin_api; export 'package:flutter_map/flutter_map.dart'; -export 'package:flutter_map/src/map/state.dart'; export 'package:flutter_map/src/misc/private/bounds.dart'; -export 'package:flutter_map/src/misc/private/positioned_tap_detector_2.dart'; export 'package:flutter_map/src/misc/private/util.dart'; -// ignore: invalid_export_of_internal_element -export 'package:flutter_map/src/map/controller.dart' show MapControllerImpl; diff --git a/lib/src/geo/crs.dart b/lib/src/geo/crs.dart index df4aee993..c0adefafb 100644 --- a/lib/src/geo/crs.dart +++ b/lib/src/geo/crs.dart @@ -1,7 +1,7 @@ import 'dart:math' as math; -import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:flutter_map/src/misc/point.dart'; +import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; import 'package:proj4dart/proj4dart.dart' as proj4; diff --git a/lib/src/gestures/flutter_map_interactive_viewer.dart b/lib/src/gestures/flutter_map_interactive_viewer.dart new file mode 100644 index 000000000..96171be4e --- /dev/null +++ b/lib/src/gestures/flutter_map_interactive_viewer.dart @@ -0,0 +1,865 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.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/multi_finger_gesture.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; +import 'package:flutter_map/src/map/internal_controller.dart'; +import 'package:flutter_map/src/map/options.dart'; +import 'package:flutter_map/src/misc/point.dart'; +import 'package:flutter_map/src/misc/private/positioned_tap_detector_2.dart'; +import 'package:latlong2/latlong.dart'; + +/// Applies interactions (gestures/scroll/taps etc) to the current [MapCamera] +/// via the internal [controller]. +class FlutterMapInteractiveViewer extends StatefulWidget { + final Widget Function( + BuildContext context, + MapOptions options, + MapCamera camera, + ) builder; + final FlutterMapInternalController controller; + + const FlutterMapInteractiveViewer({ + super.key, + required this.builder, + required this.controller, + }); + + @override + State createState() => + FlutterMapInteractiveViewerState(); +} + +class FlutterMapInteractiveViewerState + extends State with TickerProviderStateMixin { + static const int _kMinFlingVelocity = 800; + static const _kDoubleTapZoomDuration = 200; + + 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; + + 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); + } + + void _onMapStateChange() { + setState(() {}); + } + + @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(); + + super.dispose(); + } + + void updateGestures( + InteractionOptions oldOptions, + InteractionOptions newOptions, + ) { + if (newOptions.dragEnabled != oldOptions.dragEnabled) { + _gestures = _createGestures(dragEnabled: newOptions.dragEnabled); + } + + if (!newOptions.flingEnabled) { + _closeFlingAnimationController(MapEventSource.interactiveFlagsChanged); + } + if (newOptions.doubleTapZoomEnabled) { + _closeDoubleTapController(MapEventSource.interactiveFlagsChanged); + } + + final gestures = _getMultiFingerGestureFlags(newOptions); + + if (_rotationStarted && + !newOptions.rotateEnabled && + !MultiFingerGesture.hasRotate(gestures)) { + _rotationStarted = false; + + if (_gestureWinner == MultiFingerGesture.rotate) { + _gestureWinner = MultiFingerGesture.none; + } + + widget.controller.rotateEnded(MapEventSource.interactiveFlagsChanged); + } + + var emitMapEventMoveEnd = false; + + if (_pinchZoomStarted && + !newOptions.pinchZoomEnabled && + !MultiFingerGesture.hasPinchZoom(gestures)) { + _pinchZoomStarted = false; + emitMapEventMoveEnd = true; + + if (_gestureWinner == MultiFingerGesture.pinchZoom) { + _gestureWinner = MultiFingerGesture.none; + } + } + + if (_pinchMoveStarted && + !newOptions.pinchMoveEnabled && + !MultiFingerGesture.hasPinchMove(gestures)) { + _pinchMoveStarted = false; + emitMapEventMoveEnd = true; + + if (_gestureWinner == MultiFingerGesture.pinchMove) { + _gestureWinner = MultiFingerGesture.none; + } + } + + if (_dragStarted && !newOptions.dragEnabled) { + _dragStarted = false; + emitMapEventMoveEnd = true; + } + + if (emitMapEventMoveEnd) { + widget.controller.moveEnded(MapEventSource.interactiveFlagsChanged); + } + } + + Map _createGestures({ + required bool dragEnabled, + }) { + final gestureSettings = MediaQuery.gestureSettingsOf(context); + return { + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance + ..onTapDown = _positionedTapController.onTapDown + ..onTapUp = _handleOnTapUp + ..onTap = _positionedTapController.onTap + ..onSecondaryTap = _positionedTapController.onSecondaryTap + ..onSecondaryTapDown = _positionedTapController.onTapDown; + }, + ), + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(debugOwner: this), + (LongPressGestureRecognizer instance) { + instance.onLongPress = _positionedTapController.onLongPress; + }, + ), + if (dragEnabled) + VerticalDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => VerticalDragGestureRecognizer(debugOwner: this), + (VerticalDragGestureRecognizer instance) { + instance.onUpdate = (details) { + // Absorbing vertical drags + }; + instance.gestureSettings = gestureSettings; + instance.team ??= _gestureArenaTeam; + }, + ), + if (dragEnabled) + HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers< + HorizontalDragGestureRecognizer>( + () => HorizontalDragGestureRecognizer(debugOwner: this), + (HorizontalDragGestureRecognizer instance) { + instance.onUpdate = (details) { + // Absorbing horizontal drags + }; + instance.gestureSettings = gestureSettings; + instance.team ??= _gestureArenaTeam; + }, + ), + ScaleGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => ScaleGestureRecognizer(debugOwner: this), + (ScaleGestureRecognizer instance) { + instance + ..onStart = _handleScaleStart + ..onUpdate = _handleScaleUpdate + ..onEnd = _handleScaleEnd; + instance.team ??= _gestureArenaTeam; + _gestureArenaTeam.captain = instance; + }, + ), + }; + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _onPointerDown, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerCancel, + onPointerHover: _onPointerHover, + 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 (_options.onPointerDown != null) { + final latlng = _camera.offsetToCrs(event.localPosition); + _options.onPointerDown!(event, latlng); + } + } + + void _onPointerUp(PointerUpEvent event) { + --_pointerCounter; + + 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 _onPointerSignal(PointerSignalEvent pointerSignal) { + // Handle mouse scroll events if the enableScrollWheel parameter is enabled + if (pointerSignal is PointerScrollEvent && + _interactionOptions.enableScrollWheel && + 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.toCustomPoint(), + newZoom, + ); + widget.controller.move( + newCenter, + newZoom, + offset: Offset.zero, + hasGesture: false, + source: MapEventSource.scrollWheel, + id: null, + ); + }, + ); + } + } + + 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; + } + } + + 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 = radianToDeg(details.rotation); + 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) { + 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, + ) { + LatLng newCenter = _camera.center; + double 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.move( + newCenter, + newZoom, + offset: Offset.zero, + hasGesture: true, + source: MapEventSource.onMultiFinger, + id: null, + ); + } + } + + 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.toCustomPoint(); + 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(degToRadian(rotationDiff)); + final newCenter = rotationCenter + rotatedVector; + + widget.controller.moveAndRotate( + _camera.unproject(newCenter), + _camera.zoom, + _camera.rotation + rotationDiff, + offset: Offset.zero, + hasGesture: true, + source: MapEventSource.onMultiFinger, + id: null, + ); + } + } + + 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); + } + + 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) { + _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) { + _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!.toCustomPoint(), + 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.move( + _doubleTapCenterAnimation.value, + _doubleTapZoomAnimation.value, + offset: Offset.zero, + hasGesture: true, + source: MapEventSource.doubleTapZoomAnimationController, + id: null, + ); + } + + void _handleOnTapUp(TapUpDetails details) { + _doubleTapHoldMaxDelay?.cancel(); + + if (++_tapUpCounter == 1) { + _doubleTapHoldMaxDelay = + Timer(const Duration(milliseconds: 350), _resetDoubleTapHold); + } + } + + void _handleDoubleTapHold(ScaleUpdateDetails details) { + _doubleTapHoldMaxDelay?.cancel(); + + final flags = _interactionOptions.flags; + if (InteractiveFlag.hasPinchZoom(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.move( + _camera.center, + actualZoom, + offset: Offset.zero, + hasGesture: true, + source: MapEventSource.doubleTapHold, + id: null, + ); + } + } + + void _handleFlingAnimation() { + if (!_flingAnimationStarted) { + _flingAnimationStarted = true; + widget.controller.flingStarted(MapEventSource.flingAnimationController); + _startListeningForAnimationInterruptions(); + } + + final newCenterPoint = _camera.project(_mapCenterStart) + + _flingAnimation.value.toCustomPoint().rotate(_camera.rotationRad); + final newCenter = _camera.unproject(newCenterPoint); + + widget.controller.move( + newCenter, + _camera.zoom, + offset: Offset.zero, + hasGesture: true, + source: MapEventSource.flingAnimationController, + id: null, + ); + } + + 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; + } + + 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/gestures.dart b/lib/src/gestures/gestures.dart deleted file mode 100644 index 2fcf4db17..000000000 --- a/lib/src/gestures/gestures.dart +++ /dev/null @@ -1,851 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/gestures/latlng_tween.dart'; -import 'package:flutter_map/src/map/state.dart'; -import 'package:latlong2/latlong.dart'; - -abstract class MapGestureMixin extends State - with TickerProviderStateMixin { - static const int _kMinFlingVelocity = 800; - - var _dragMode = false; - var _gestureWinner = MultiFingerGesture.none; - - var _pointerCounter = 0; - - bool _isListeningForInterruptions = false; - - void onPointerDown(PointerDownEvent event) { - ++_pointerCounter; - if (mapState.options.onPointerDown != null) { - final latlng = _offsetToCrs(event.localPosition); - mapState.options.onPointerDown!(event, latlng); - } - } - - void onPointerUp(PointerUpEvent event) { - --_pointerCounter; - if (mapState.options.onPointerUp != null) { - final latlng = _offsetToCrs(event.localPosition); - mapState.options.onPointerUp!(event, latlng); - } - } - - void onPointerCancel(PointerCancelEvent event) { - --_pointerCounter; - if (mapState.options.onPointerCancel != null) { - final latlng = _offsetToCrs(event.localPosition); - mapState.options.onPointerCancel!(event, latlng); - } - } - - void onPointerHover(PointerHoverEvent event) { - if (mapState.options.onPointerHover != null) { - final latlng = _offsetToCrs(event.localPosition); - mapState.options.onPointerHover!(event, latlng); - } - } - - void onPointerSignal(PointerSignalEvent pointerSignal) { - // Handle mouse scroll events if the enableScrollWheel parameter is enabled - if (pointerSignal is PointerScrollEvent && - mapState.options.enableScrollWheel && - 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 = mapState.options.minZoom ?? 0.0; - final maxZoom = mapState.options.maxZoom ?? double.infinity; - final newZoom = (mapState.zoom - - pointerSignal.scrollDelta.dy * - mapState.options.scrollWheelVelocity) - .clamp(minZoom, maxZoom); - // Calculate offset of mouse cursor from viewport center - final List newCenterZoom = _getNewEventCenterZoomPosition( - _offsetToPoint(pointerSignal.localPosition), newZoom); - - // Move to new center and zoom level - mapState.move(newCenterZoom[0] as LatLng, newCenterZoom[1] as double, - source: MapEventSource.scrollWheel); - }); - } - } - - 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; - late Animation _flingAnimation; - - late final AnimationController _doubleTapController; - late Animation _doubleTapZoomAnimation; - late Animation _doubleTapCenterAnimation; - - int _tapUpCounter = 0; - Timer? _doubleTapHoldMaxDelay; - - @override - FlutterMap get widget; - - FlutterMapState get mapState; - - MapController get mapController; - - MapOptions get options; - - @override - void initState() { - super.initState(); - _flingController = AnimationController(vsync: this) - ..addListener(_handleFlingAnimation) - ..addStatusListener(_flingAnimationStatusListener); - _doubleTapController = AnimationController( - vsync: this, duration: const Duration(milliseconds: 200)) - ..addListener(_handleDoubleTapZoomAnimation) - ..addStatusListener(_doubleTapZoomStatusListener); - } - - @override - void didUpdateWidget(FlutterMap oldWidget) { - super.didUpdateWidget(oldWidget); - - final oldFlags = oldWidget.options.interactiveFlags; - final flags = options.interactiveFlags; - - final oldGestures = - _getMultiFingerGestureFlags(mapOptions: oldWidget.options); - final gestures = _getMultiFingerGestureFlags(); - - if (flags != oldFlags || gestures != oldGestures) { - var emitMapEventMoveEnd = false; - - if (!InteractiveFlag.hasFlag(flags, InteractiveFlag.flingAnimation)) { - closeFlingAnimationController(MapEventSource.interactiveFlagsChanged); - } - if (!InteractiveFlag.hasFlag(flags, InteractiveFlag.doubleTapZoom)) { - closeDoubleTapController(MapEventSource.interactiveFlagsChanged); - } - - if (_rotationStarted && - !(InteractiveFlag.hasFlag(flags, InteractiveFlag.rotate) && - MultiFingerGesture.hasFlag( - gestures, MultiFingerGesture.rotate))) { - _rotationStarted = false; - - if (_gestureWinner == MultiFingerGesture.rotate) { - _gestureWinner = MultiFingerGesture.none; - } - - mapState.emitMapEvent( - MapEventRotateEnd( - center: mapState.center, - zoom: mapState.zoom, - source: MapEventSource.interactiveFlagsChanged, - ), - ); - } - - if (_pinchZoomStarted && - !(InteractiveFlag.hasFlag(flags, InteractiveFlag.pinchZoom) && - MultiFingerGesture.hasFlag( - gestures, MultiFingerGesture.pinchZoom))) { - _pinchZoomStarted = false; - emitMapEventMoveEnd = true; - - if (_gestureWinner == MultiFingerGesture.pinchZoom) { - _gestureWinner = MultiFingerGesture.none; - } - } - - if (_pinchMoveStarted && - !(InteractiveFlag.hasFlag(flags, InteractiveFlag.pinchMove) && - MultiFingerGesture.hasFlag( - gestures, MultiFingerGesture.pinchMove))) { - _pinchMoveStarted = false; - emitMapEventMoveEnd = true; - - if (_gestureWinner == MultiFingerGesture.pinchMove) { - _gestureWinner = MultiFingerGesture.none; - } - } - - if (_dragStarted && - !InteractiveFlag.hasFlag(flags, InteractiveFlag.drag)) { - _dragStarted = false; - emitMapEventMoveEnd = true; - } - - if (emitMapEventMoveEnd) { - mapState.emitMapEvent( - MapEventRotateEnd( - center: mapState.center, - zoom: mapState.zoom, - source: MapEventSource.interactiveFlagsChanged, - ), - ); - } - } - } - - void _yieldMultiFingerGestureWinner( - int gestureWinner, bool resetStartVariables) { - _gestureWinner = gestureWinner; - - if (resetStartVariables) { - // note: here we could reset to current values instead of last values - _scaleCorrector = 1.0 - _lastScale; - } - } - - int _getMultiFingerGestureFlags( - {int? gestureWinner, MapOptions? mapOptions}) { - gestureWinner ??= _gestureWinner; - mapOptions ??= options; - - if (mapOptions.enableMultiFingerGestureRace) { - if (gestureWinner == MultiFingerGesture.pinchZoom) { - return mapOptions.pinchZoomWinGestures; - } else if (gestureWinner == MultiFingerGesture.rotate) { - return mapOptions.rotationWinGestures; - } else if (gestureWinner == MultiFingerGesture.pinchMove) { - return mapOptions.pinchMoveWinGestures; - } - - return MultiFingerGesture.none; - } else { - return MultiFingerGesture.all; - } - } - - void closeFlingAnimationController(MapEventSource source) { - _flingAnimationStarted = false; - if (_flingController.isAnimating) { - _flingController.stop(); - - _stopListeningForAnimationInterruptions(); - - mapState.emitMapEvent( - MapEventFlingAnimationEnd( - center: mapState.center, zoom: mapState.zoom, source: source), - ); - } - } - - void closeDoubleTapController(MapEventSource source) { - if (_doubleTapController.isAnimating) { - _doubleTapController.stop(); - - _stopListeningForAnimationInterruptions(); - - mapState.emitMapEvent( - MapEventDoubleTapZoomEnd( - center: mapState.center, zoom: mapState.zoom, source: source), - ); - } - } - - void handleScaleStart(ScaleStartDetails details) { - _dragMode = _pointerCounter == 1; - - final eventSource = _dragMode - ? MapEventSource.dragStart - : MapEventSource.multiFingerGestureStart; - closeFlingAnimationController(eventSource); - closeDoubleTapController(eventSource); - - _gestureWinner = MultiFingerGesture.none; - - _mapZoomStart = mapState.zoom; - _mapCenterStart = mapState.center; - _focalStartLocal = _lastFocalLocal = details.localFocalPoint; - _focalStartLatLng = _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 eventSource = - _dragMode ? MapEventSource.onDrag : MapEventSource.onMultiFinger; - - final flags = options.interactiveFlags; - final focalOffset = details.localFocalPoint; - - final currentRotation = radianToDeg(details.rotation); - - if (_dragMode) { - if (InteractiveFlag.hasFlag(flags, InteractiveFlag.drag)) { - 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; - mapState.emitMapEvent( - MapEventMoveStart( - center: mapState.center, - zoom: mapState.zoom, - source: eventSource, - ), - ); - } - - final oldCenterPt = mapState.project(mapState.center, mapState.zoom); - final localDistanceOffset = - _rotateOffset(_lastFocalLocal - focalOffset); - - final newCenterPt = oldCenterPt + _offsetToPoint(localDistanceOffset); - final newCenter = mapState.unproject(newCenterPt, mapState.zoom); - - mapState.move( - newCenter, - mapState.zoom, - hasGesture: true, - source: eventSource, - ); - } - } else { - final hasIntPinchMove = - InteractiveFlag.hasFlag(flags, InteractiveFlag.pinchMove); - final hasIntPinchZoom = - InteractiveFlag.hasFlag(flags, InteractiveFlag.pinchZoom); - final hasIntRotate = - InteractiveFlag.hasFlag(flags, InteractiveFlag.rotate); - - if (hasIntPinchMove || hasIntPinchZoom || hasIntRotate) { - final hasGestureRace = options.enableMultiFingerGestureRace; - - if (hasGestureRace && _gestureWinner == MultiFingerGesture.none) { - if (hasIntPinchZoom && - (_getZoomForScale(_mapZoomStart, details.scale) - _mapZoomStart) - .abs() >= - options.pinchZoomThreshold) { - if (options.debugMultiFingerGestureWinner) { - debugPrint('Multi Finger Gesture winner: Pinch Zoom'); - } - _yieldMultiFingerGestureWinner(MultiFingerGesture.pinchZoom, true); - } else if (hasIntRotate && - currentRotation.abs() >= options.rotationThreshold) { - if (options.debugMultiFingerGestureWinner) { - debugPrint('Multi Finger Gesture winner: Rotate'); - } - _yieldMultiFingerGestureWinner(MultiFingerGesture.rotate, true); - } else if (hasIntPinchMove && - (_focalStartLocal - focalOffset).distance >= - options.pinchMoveThreshold) { - if (options.debugMultiFingerGestureWinner) { - debugPrint('Multi Finger Gesture winner: Pinch Move'); - } - _yieldMultiFingerGestureWinner(MultiFingerGesture.pinchMove, true); - } - } - - if (!hasGestureRace || _gestureWinner != MultiFingerGesture.none) { - final gestures = _getMultiFingerGestureFlags(); - - final hasGesturePinchMove = MultiFingerGesture.hasFlag( - gestures, MultiFingerGesture.pinchMove); - final hasGesturePinchZoom = MultiFingerGesture.hasFlag( - gestures, MultiFingerGesture.pinchZoom); - final hasGestureRotate = - MultiFingerGesture.hasFlag(gestures, MultiFingerGesture.rotate); - - final hasMove = hasIntPinchMove && hasGesturePinchMove; - final hasZoom = hasIntPinchZoom && hasGesturePinchZoom; - final hasRotate = hasIntRotate && hasGestureRotate; - - var mapMoved = false; - var mapRotated = false; - if (hasMove || hasZoom) { - double newZoom; - // checking details.scale to prevent situation whew details comes - // with zero scale - if (hasZoom && details.scale > 0.0) { - newZoom = _getZoomForScale( - _mapZoomStart, details.scale + _scaleCorrector); - - if (!_pinchZoomStarted) { - if (newZoom != _mapZoomStart) { - _pinchZoomStarted = true; - - if (!_pinchMoveStarted) { - // emit MoveStart event only if pinchMove hasn't started - mapState.emitMapEvent( - MapEventMoveStart( - center: mapState.center, - zoom: mapState.zoom, - source: eventSource, - ), - ); - } - } - } - } else { - newZoom = mapState.zoom; - } - - LatLng newCenter; - if (hasMove) { - if (!_pinchMoveStarted && _lastFocalLocal != focalOffset) { - _pinchMoveStarted = true; - - if (!_pinchZoomStarted) { - // emit MoveStart event only if pinchZoom hasn't started - mapState.emitMapEvent( - MapEventMoveStart( - center: mapState.center, - zoom: mapState.zoom, - source: eventSource, - ), - ); - } - } - - if (_pinchZoomStarted || _pinchMoveStarted) { - final oldCenterPt = mapState.project(mapState.center, newZoom); - final newFocalLatLong = _offsetToCrs(_focalStartLocal, newZoom); - final newFocalPt = mapState.project(newFocalLatLong, newZoom); - final oldFocalPt = mapState.project(_focalStartLatLng, newZoom); - final zoomDifference = oldFocalPt - newFocalPt; - final moveDifference = - _rotateOffset(_focalStartLocal - _lastFocalLocal); - - final newCenterPt = oldCenterPt + - zoomDifference + - _offsetToPoint(moveDifference); - newCenter = mapState.unproject(newCenterPt, newZoom); - } else { - newCenter = mapState.center; - } - } else { - newCenter = mapState.center; - } - - if (_pinchZoomStarted || _pinchMoveStarted) { - mapMoved = mapState.move( - newCenter, - newZoom, - hasGesture: true, - source: eventSource, - ); - } - } - - if (hasRotate) { - if (!_rotationStarted && currentRotation != 0.0) { - _rotationStarted = true; - mapState.emitMapEvent( - MapEventRotateStart( - center: mapState.center, - zoom: mapState.zoom, - source: eventSource, - ), - ); - } - - if (_rotationStarted) { - final rotationDiff = currentRotation - _lastRotation; - final oldCenterPt = mapState.project(mapState.center); - final rotationCenter = - mapState.project(_offsetToCrs(_lastFocalLocal)); - final vector = oldCenterPt - rotationCenter; - final rotatedVector = vector.rotate(degToRadian(rotationDiff)); - final newCenter = rotationCenter + rotatedVector; - mapMoved = mapState.move( - mapState.unproject(newCenter), mapState.zoom, - source: eventSource) || - mapMoved; - mapRotated = mapState.rotate( - mapState.rotation + rotationDiff, - hasGesture: true, - source: eventSource, - ); - } - } - - if (mapMoved || mapRotated) mapState.setState(() {}); - } - } - } - - _lastRotation = currentRotation; - _lastScale = details.scale; - _lastFocalLocal = focalOffset; - } - - void handleScaleEnd(ScaleEndDetails details) { - _resetDoubleTapHold(); - - final eventSource = - _dragMode ? MapEventSource.dragEnd : MapEventSource.multiFingerEnd; - - if (_rotationStarted) { - _rotationStarted = false; - mapState.emitMapEvent( - MapEventRotateEnd( - center: mapState.center, - zoom: mapState.zoom, - source: eventSource, - ), - ); - } - - if (_dragStarted || _pinchZoomStarted || _pinchMoveStarted) { - _dragStarted = _pinchZoomStarted = _pinchMoveStarted = false; - mapState.emitMapEvent( - MapEventMoveEnd( - center: mapState.center, - zoom: mapState.zoom, - source: eventSource, - ), - ); - } - - final hasFling = InteractiveFlag.hasFlag( - options.interactiveFlags, InteractiveFlag.flingAnimation); - - final magnitude = details.velocity.pixelsPerSecond.distance; - if (magnitude < _kMinFlingVelocity || !hasFling) { - if (hasFling) { - mapState.emitMapEvent( - MapEventFlingAnimationNotStarted( - center: mapState.center, - zoom: mapState.zoom, - source: eventSource, - ), - ); - } - - return; - } - - final direction = details.velocity.pixelsPerSecond / magnitude; - final distance = (Offset.zero & - Size(mapState.nonrotatedSize.x, mapState.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) { - closeFlingAnimationController(MapEventSource.tap); - closeDoubleTapController(MapEventSource.tap); - - final relativePosition = position.relative; - if (relativePosition == null) return; - - final latlng = _offsetToCrs(relativePosition); - final onTap = options.onTap; - if (onTap != null) { - // emit the event - onTap(position, latlng); - } - - mapState.emitMapEvent( - MapEventTap( - tapPosition: latlng, - center: mapState.center, - zoom: mapState.zoom, - source: MapEventSource.tap, - ), - ); - } - - void handleSecondaryTap(TapPosition position) { - closeFlingAnimationController(MapEventSource.secondaryTap); - closeDoubleTapController(MapEventSource.secondaryTap); - - final relativePosition = position.relative; - if (relativePosition == null) return; - - final latlng = _offsetToCrs(relativePosition); - final onSecondaryTap = options.onSecondaryTap; - if (onSecondaryTap != null) { - // emit the event - onSecondaryTap(position, latlng); - } - - mapState.emitMapEvent( - MapEventSecondaryTap( - tapPosition: latlng, - center: mapState.center, - zoom: mapState.zoom, - source: MapEventSource.secondaryTap, - ), - ); - } - - void handleLongPress(TapPosition position) { - _resetDoubleTapHold(); - - closeFlingAnimationController(MapEventSource.longPress); - closeDoubleTapController(MapEventSource.longPress); - - final latlng = _offsetToCrs(position.relative!); - if (options.onLongPress != null) { - // emit the event - options.onLongPress!(position, latlng); - } - - mapState.emitMapEvent( - MapEventLongPress( - tapPosition: latlng, - center: mapState.center, - zoom: mapState.zoom, - source: MapEventSource.longPress, - ), - ); - } - - LatLng _offsetToCrs(Offset offset, [double? zoom]) { - final focalStartPt = - mapState.project(mapState.center, zoom ?? mapState.zoom); - final point = (_offsetToPoint(offset) - (mapState.nonrotatedSize / 2.0)) - .rotate(mapState.rotationRad); - - final newCenterPt = focalStartPt + point; - return mapState.unproject(newCenterPt, zoom ?? mapState.zoom); - } - - void handleDoubleTap(TapPosition tapPosition) { - _resetDoubleTapHold(); - - closeFlingAnimationController(MapEventSource.doubleTap); - closeDoubleTapController(MapEventSource.doubleTap); - - if (InteractiveFlag.hasFlag( - options.interactiveFlags, InteractiveFlag.doubleTapZoom)) { - final centerZoom = _getNewEventCenterZoomPosition( - _offsetToPoint(tapPosition.relative!), - _getZoomForScale(mapState.zoom, 2)); - _startDoubleTapAnimation( - centerZoom[1] as double, centerZoom[0] as LatLng); - } - } - - // If we double click in the corner of a map, calculate the new - // center of the whole map after a zoom, to retain that offset position - // so that the same event LatLng is still under the cursor. - - List _getNewEventCenterZoomPosition( - CustomPoint cursorPos, double newZoom) { - // Calculate offset of mouse cursor from viewport center - final viewCenter = mapState.nonrotatedSize / 2; - final offset = (cursorPos - viewCenter).rotate(mapState.rotationRad); - // Match new center coordinate to mouse cursor position - final scale = mapState.getZoomScale(newZoom, mapState.zoom); - final newOffset = offset * (1.0 - 1.0 / scale); - final mapCenter = mapState.project(mapState.center); - final newCenter = mapState.unproject(mapCenter + newOffset); - return [newCenter, newZoom]; - } - - void _startDoubleTapAnimation(double newZoom, LatLng newCenter) { - _doubleTapZoomAnimation = Tween(begin: mapState.zoom, end: newZoom) - .chain(CurveTween(curve: Curves.linear)) - .animate(_doubleTapController); - _doubleTapCenterAnimation = - LatLngTween(begin: mapState.center, end: newCenter) - .chain(CurveTween(curve: Curves.linear)) - .animate(_doubleTapController); - _doubleTapController.forward(from: 0); - } - - void _doubleTapZoomStatusListener(AnimationStatus status) { - if (status == AnimationStatus.forward) { - mapState.emitMapEvent( - MapEventDoubleTapZoomStart( - center: mapState.center, - zoom: mapState.zoom, - source: MapEventSource.doubleTapZoomAnimationController), - ); - _startListeningForAnimationInterruptions(); - } else if (status == AnimationStatus.completed) { - _stopListeningForAnimationInterruptions(); - mapState.emitMapEvent( - MapEventDoubleTapZoomEnd( - center: mapState.center, - zoom: mapState.zoom, - source: MapEventSource.doubleTapZoomAnimationController), - ); - } - } - - void _handleDoubleTapZoomAnimation() { - mapState.move( - _doubleTapCenterAnimation.value, - _doubleTapZoomAnimation.value, - hasGesture: true, - source: MapEventSource.doubleTapZoomAnimationController, - ); - } - - void handleOnTapUp(TapUpDetails details) { - _doubleTapHoldMaxDelay?.cancel(); - - if (++_tapUpCounter == 1) { - _doubleTapHoldMaxDelay = - Timer(const Duration(milliseconds: 350), _resetDoubleTapHold); - } - } - - void _handleDoubleTapHold(ScaleUpdateDetails details) { - _doubleTapHoldMaxDelay?.cancel(); - - final flags = options.interactiveFlags; - if (InteractiveFlag.hasFlag(flags, InteractiveFlag.pinchZoom)) { - final zoom = mapState.zoom; - final focalOffset = details.localFocalPoint; - final verticalOffset = (_focalStartLocal - focalOffset).dy; - final newZoom = _mapZoomStart - verticalOffset / 360 * zoom; - final min = options.minZoom ?? 0.0; - final max = options.maxZoom ?? double.infinity; - final actualZoom = math.max(min, math.min(max, newZoom)); - - mapState.move( - mapState.center, - actualZoom, - hasGesture: true, - source: MapEventSource.doubleTapHold, - ); - } - } - - void _resetDoubleTapHold() { - _doubleTapHoldMaxDelay?.cancel(); - _tapUpCounter = 0; - } - - void _flingAnimationStatusListener(AnimationStatus status) { - if (status == AnimationStatus.completed) { - _flingAnimationStarted = false; - _stopListeningForAnimationInterruptions(); - mapState.emitMapEvent( - MapEventFlingAnimationEnd( - center: mapState.center, - zoom: mapState.zoom, - source: MapEventSource.flingAnimationController), - ); - } - } - - void _handleFlingAnimation() { - if (!_flingAnimationStarted) { - _flingAnimationStarted = true; - mapState.emitMapEvent( - MapEventFlingAnimationStart( - center: mapState.center, - zoom: mapState.zoom, - source: MapEventSource.flingAnimationController), - ); - _startListeningForAnimationInterruptions(); - } - - final newCenterPoint = mapState.project(_mapCenterStart) + - _offsetToPoint(_flingAnimation.value).rotate(mapState.rotationRad); - final newCenter = mapState.unproject(newCenterPoint); - - mapState.move( - newCenter, - mapState.zoom, - hasGesture: true, - source: MapEventSource.flingAnimationController, - ); - } - - void _startListeningForAnimationInterruptions() { - _isListeningForInterruptions = true; - } - - void _stopListeningForAnimationInterruptions() { - _isListeningForInterruptions = false; - } - - void handleAnimationInterruptions(MapEvent event) { - if (_isListeningForInterruptions == false) { - //Do not handle animation interruptions if not listening - return; - } - closeDoubleTapController(event.source); - closeFlingAnimationController(event.source); - } - - CustomPoint _offsetToPoint(Offset offset) { - return CustomPoint(offset.dx, offset.dy); - } - - double _getZoomForScale(double startZoom, double scale) { - final resultZoom = - scale == 1.0 ? startZoom : startZoom + math.log(scale) / math.ln2; - return mapState.fitZoomToBounds(resultZoom); - } - - Offset _rotateOffset(Offset offset) { - final radians = mapState.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; - } - - @override - void dispose() { - _flingController.dispose(); - _doubleTapController.dispose(); - super.dispose(); - } -} diff --git a/lib/src/gestures/interactive_flag.dart b/lib/src/gestures/interactive_flag.dart index a9e60813a..7cb621353 100644 --- a/lib/src/gestures/interactive_flag.dart +++ b/lib/src/gestures/interactive_flag.dart @@ -3,34 +3,41 @@ /// disable all events /// /// If you want mix interactions for example drag and rotate interactions then -/// you have two options A.) add you own flags: [InteractiveFlag.drag] | -/// [InteractiveFlag.rotate] B.) remove unnecessary flags from all: -/// [InteractiveFlag.all] & ~[InteractiveFlag.flingAnimation] & -/// ~[InteractiveFlag.pinchMove] & ~[InteractiveFlag.pinchZoom] & -/// ~[InteractiveFlag.doubleTapZoom] +/// you have two options: +/// a. Add you own flags: [InteractiveFlag.drag] | [InteractiveFlag.rotate] +/// b. Remove unnecessary flags from all: +/// [InteractiveFlag.all] & +/// ~[InteractiveFlag.flingAnimation] & +/// ~[InteractiveFlag.pinchMove] & +/// ~[InteractiveFlag.pinchZoom] & +/// ~[InteractiveFlag.doubleTapZoom] class InteractiveFlag { + const InteractiveFlag._(); static const int all = drag | flingAnimation | pinchMove | pinchZoom | doubleTapZoom | rotate; static const int none = 0; - // enable move with one finger + // Enable move with one finger. static const int drag = 1 << 0; - // enable fling animation when drag or pinchMove have enough Fling Velocity + // Enable fling animation when drag or pinchMove have enough Fling Velocity. static const int flingAnimation = 1 << 1; - // enable move with two or more fingers + // Enable move with two or more fingers. static const int pinchMove = 1 << 2; - // enable pinch zoom + // Enable pinch zoom. static const int pinchZoom = 1 << 3; - // enable double tap zoom animation + // Enable double tap zoom animation. static const int doubleTapZoom = 1 << 4; - // enable map rotate + /// Enable map rotate. static const int rotate = 1 << 5; + /// 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] | @@ -39,4 +46,25 @@ class InteractiveFlag { 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 [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); } diff --git a/lib/src/gestures/map_events.dart b/lib/src/gestures/map_events.dart index 4499b2cc1..a943ee7a2 100644 --- a/lib/src/gestures/map_events.dart +++ b/lib/src/gestures/map_events.dart @@ -1,4 +1,4 @@ -import 'package:flutter_map/src/misc/point.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; import 'package:latlong2/latlong.dart'; /// Event sources which are used to identify different types of @@ -19,7 +19,7 @@ enum MapEventSource { flingAnimationController, doubleTapZoomAnimationController, interactiveFlagsChanged, - fitBounds, + fitCamera, custom, scrollWheel, nonRotatedSizeChange, @@ -32,68 +32,51 @@ abstract class MapEvent { /// Who / what issued the event. final MapEventSource source; - /// Geographical coordinates related to current event. - final LatLng center; - - /// Zoom value related to current event. - final double zoom; + /// The map camera after the event. + final MapCamera camera; const MapEvent({ required this.source, - required this.center, - required this.zoom, + required this.camera, }); } /// Base event class which is emitted by MapController instance and /// includes information about camera movement +/// which are not partial (e.g start rotate, rotate, end rotate). abstract class MapEventWithMove extends MapEvent { - /// Target coordinates of point where map is being pointed to - final LatLng targetCenter; - - /// Zoom value of point where map is being pointed to - final double targetZoom; + final MapCamera oldCamera; const MapEventWithMove({ - required this.targetCenter, - required this.targetZoom, required super.source, - required super.center, - required super.zoom, + required this.oldCamera, + required super.camera, }); /// Returns a subclass of [MapEventWithMove] if the [source] belongs to a /// movement event, otherwise returns null. static MapEventWithMove? fromSource({ - required LatLng targetCenter, - required double targetZoom, - required LatLng oldCenter, - required double oldZoom, + required MapCamera oldCamera, + required MapCamera camera, required bool hasGesture, required MapEventSource source, String? id, }) => switch (source) { MapEventSource.flingAnimationController => MapEventFlingAnimation( - center: oldCenter, - zoom: oldZoom, - targetCenter: targetCenter, - targetZoom: targetZoom, + oldCamera: oldCamera, + camera: camera, source: source, ), MapEventSource.doubleTapZoomAnimationController => MapEventDoubleTapZoom( - center: oldCenter, - zoom: oldZoom, - targetCenter: targetCenter, - targetZoom: targetZoom, + oldCamera: oldCamera, + camera: camera, source: source, ), MapEventSource.scrollWheel => MapEventScrollWheelZoom( - center: oldCenter, - zoom: oldZoom, - targetCenter: targetCenter, - targetZoom: targetZoom, + oldCamera: oldCamera, + camera: camera, source: source, ), MapEventSource.onDrag || @@ -102,10 +85,8 @@ abstract class MapEventWithMove extends MapEvent { MapEventSource.custom => MapEventMove( id: id, - center: oldCenter, - zoom: oldZoom, - targetCenter: targetCenter, - targetZoom: targetZoom, + oldCamera: oldCamera, + camera: camera, source: source, ), _ => null, @@ -120,8 +101,7 @@ class MapEventTap extends MapEvent { const MapEventTap({ required this.tapPosition, required super.source, - required super.center, - required super.zoom, + required super.camera, }); } @@ -132,8 +112,7 @@ class MapEventSecondaryTap extends MapEvent { const MapEventSecondaryTap({ required this.tapPosition, required super.source, - required super.center, - required super.zoom, + required super.camera, }); } @@ -145,8 +124,7 @@ class MapEventLongPress extends MapEvent { const MapEventLongPress({ required this.tapPosition, required super.source, - required super.center, - required super.zoom, + required super.camera, }); } @@ -157,23 +135,17 @@ class MapEventMove extends MapEventWithMove { const MapEventMove({ this.id, - required LatLng targetCenter, - required double targetZoom, required super.source, - required super.center, - required super.zoom, - }) : super( - targetCenter: targetCenter, - targetZoom: targetZoom, - ); + required super.oldCamera, + required super.camera, + }); } /// Event which is fired when dragging is started class MapEventMoveStart extends MapEvent { const MapEventMoveStart({ required super.source, - required super.center, - required super.zoom, + required super.camera, }); } @@ -181,23 +153,17 @@ class MapEventMoveStart extends MapEvent { class MapEventMoveEnd extends MapEvent { const MapEventMoveEnd({ required super.source, - required super.center, - required super.zoom, + required super.camera, }); } /// Event which is fired when animation started by fling gesture is in progress class MapEventFlingAnimation extends MapEventWithMove { const MapEventFlingAnimation({ - required LatLng targetCenter, - required double targetZoom, required super.source, - required super.center, - required super.zoom, - }) : super( - targetCenter: targetCenter, - targetZoom: targetZoom, - ); + required super.oldCamera, + required super.camera, + }); } /// Emits when InteractiveFlags contains fling and there wasn't enough velocity @@ -205,8 +171,7 @@ class MapEventFlingAnimation extends MapEventWithMove { class MapEventFlingAnimationNotStarted extends MapEvent { const MapEventFlingAnimationNotStarted({ required super.source, - required super.center, - required super.zoom, + required super.camera, }); } @@ -214,8 +179,7 @@ class MapEventFlingAnimationNotStarted extends MapEvent { class MapEventFlingAnimationStart extends MapEvent { const MapEventFlingAnimationStart({ required super.source, - required super.center, - required super.zoom, + required super.camera, }); } @@ -223,45 +187,33 @@ class MapEventFlingAnimationStart extends MapEvent { class MapEventFlingAnimationEnd extends MapEvent { const MapEventFlingAnimationEnd({ required super.source, - required super.center, - required super.zoom, + required super.camera, }); } /// Event which is fired when map is double tapped class MapEventDoubleTapZoom extends MapEventWithMove { const MapEventDoubleTapZoom({ - required LatLng targetCenter, - required double targetZoom, required super.source, - required super.center, - required super.zoom, - }) : super( - targetCenter: targetCenter, - targetZoom: targetZoom, - ); + required super.oldCamera, + required super.camera, + }); } /// Event which is fired when scroll wheel is used to zoom class MapEventScrollWheelZoom extends MapEventWithMove { const MapEventScrollWheelZoom({ - required LatLng targetCenter, - required double targetZoom, required super.source, - required super.center, - required super.zoom, - }) : super( - targetCenter: targetCenter, - targetZoom: targetZoom, - ); + required super.oldCamera, + required super.camera, + }); } /// Event which is fired when animation for double tap gesture is started class MapEventDoubleTapZoomStart extends MapEvent { const MapEventDoubleTapZoomStart({ required super.source, - required super.center, - required super.zoom, + required super.camera, }); } @@ -269,29 +221,20 @@ class MapEventDoubleTapZoomStart extends MapEvent { class MapEventDoubleTapZoomEnd extends MapEvent { const MapEventDoubleTapZoomEnd({ required super.source, - required super.center, - required super.zoom, + required super.camera, }); } /// Event which is fired when map is being rotated -class MapEventRotate extends MapEvent { +class MapEventRotate extends MapEventWithMove { /// Custom ID to identify related object(s) final String? id; - /// Current rotation in radians - final double currentRotation; - - /// Target rotation in radians - final double targetRotation; - const MapEventRotate({ required this.id, - required this.currentRotation, - required this.targetRotation, required super.source, - required super.center, - required super.zoom, + required super.oldCamera, + required super.camera, }); } @@ -299,25 +242,21 @@ class MapEventRotate extends MapEvent { class MapEventRotateStart extends MapEvent { const MapEventRotateStart({ required super.source, - required super.center, - required super.zoom, + required super.camera, }); } class MapEventRotateEnd extends MapEvent { const MapEventRotateEnd({ required super.source, - required super.center, - required super.zoom, + required super.camera, }); } -class MapEventNonRotatedSizeChange extends MapEvent { +class MapEventNonRotatedSizeChange extends MapEventWithMove { const MapEventNonRotatedSizeChange({ required super.source, - required CustomPoint previousNonRotatedSize, - required CustomPoint nonRotatedSize, - required super.center, - required super.zoom, + required super.oldCamera, + required super.camera, }); } diff --git a/lib/src/gestures/multi_finger_gesture.dart b/lib/src/gestures/multi_finger_gesture.dart index fa7d66bfa..5fa8a7dca 100644 --- a/lib/src/gestures/multi_finger_gesture.dart +++ b/lib/src/gestures/multi_finger_gesture.dart @@ -26,4 +26,8 @@ class MultiFingerGesture { 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/layer/attribution_layer/rich.dart b/lib/src/layer/attribution_layer/rich.dart index 4856be294..6574c34b0 100644 --- a/lib/src/layer/attribution_layer/rich.dart +++ b/lib/src/layer/attribution_layer/rich.dart @@ -239,10 +239,8 @@ class RichAttributionWidgetState extends State { context, () { setState(() => popupExpanded = true); - mapEventSubscription = FlutterMapState.of(context) - .mapController - .mapEventStream - .listen((e) { + mapEventSubscription = + MapController.of(context).mapEventStream.listen((e) { setState(() => popupExpanded = false); mapEventSubscription?.cancel(); }); diff --git a/lib/src/layer/attribution_layer/simple.dart b/lib/src/layer/attribution_layer/simple.dart index e81163e54..19465dd57 100644 --- a/lib/src/layer/attribution_layer/simple.dart +++ b/lib/src/layer/attribution_layer/simple.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; /// A simple, classic style, attribution widget, to be placed in /// [FlutterMap.nonRotatedChildren] diff --git a/lib/src/layer/circle_layer.dart b/lib/src/layer/circle_layer.dart index 0bb4f2e38..2b649980e 100644 --- a/lib/src/layer/circle_layer.dart +++ b/lib/src/layer/circle_layer.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/map/state.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; import 'package:latlong2/latlong.dart' hide Path; class CircleMarker { @@ -36,7 +36,7 @@ class CircleLayer extends StatelessWidget { return LayoutBuilder( builder: (BuildContext context, BoxConstraints bc) { final size = Size(bc.maxWidth, bc.maxHeight); - final map = FlutterMapState.of(context); + final map = MapCamera.of(context); final circleWidgets = []; for (final circle in circles) { circle.offset = map.getOffsetFromOrigin(circle.point); diff --git a/lib/src/layer/marker_layer.dart b/lib/src/layer/marker_layer.dart index b94afdd89..58bf9e7d0 100644 --- a/lib/src/layer/marker_layer.dart +++ b/lib/src/layer/marker_layer.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/map/state.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; +import 'package:flutter_map/src/misc/point.dart'; import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:latlong2/latlong.dart'; @@ -62,15 +62,7 @@ enum AnchorAlign { left(-1, 0), /// Right center - right(1, 0), - - @Deprecated( - 'Prefer `center`. ' - 'This value is equivalent to the `center` alignment. ' - 'If you notice a difference in behaviour, please open a bug report on GitHub. ' - 'This feature is deprecated since v5.', - ) - none(0, 0); + right(1, 0); final int _x; final int _y; @@ -192,7 +184,7 @@ class MarkerLayer extends StatelessWidget { @override Widget build(BuildContext context) { - final map = FlutterMapState.of(context); + final map = MapCamera.of(context); final markerWidgets = []; for (final marker in markers) { diff --git a/lib/src/layer/overlay_image_layer.dart b/lib/src/layer/overlay_image_layer.dart index 148a77ed4..2099ccb7f 100644 --- a/lib/src/layer/overlay_image_layer.dart +++ b/lib/src/layer/overlay_image_layer.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/map/state.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:latlong2/latlong.dart'; @@ -12,7 +12,7 @@ abstract class BaseOverlayImage { bool get gaplessPlayback; - Positioned buildPositionedForOverlay(FlutterMapState map); + Positioned buildPositionedForOverlay(MapCamera map); Image buildImageForOverlay() { return Image( @@ -45,7 +45,7 @@ class OverlayImage extends BaseOverlayImage { this.gaplessPlayback = false}); @override - Positioned buildPositionedForOverlay(FlutterMapState map) { + Positioned buildPositionedForOverlay(MapCamera map) { // northWest is not necessarily upperLeft depending on projection final bounds = Bounds( map.project(this.bounds.northWest) - map.pixelOrigin, @@ -92,7 +92,7 @@ class RotatedOverlayImage extends BaseOverlayImage { this.filterQuality = FilterQuality.medium}); @override - Positioned buildPositionedForOverlay(FlutterMapState map) { + Positioned buildPositionedForOverlay(MapCamera map) { final pxTopLeft = map.project(topLeftCorner) - map.pixelOrigin; final pxBottomRight = map.project(bottomRightCorner) - map.pixelOrigin; final pxBottomLeft = map.project(bottomLeftCorner) - map.pixelOrigin; @@ -136,7 +136,7 @@ class OverlayImageLayer extends StatelessWidget { @override Widget build(BuildContext context) { - final map = FlutterMapState.of(context); + final map = MapCamera.of(context); return ClipRect( child: Stack( children: [ diff --git a/lib/src/layer/polygon_layer.dart b/lib/src/layer/polygon_layer.dart index 1fca6d92b..9044f732e 100644 --- a/lib/src/layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer.dart @@ -1,9 +1,9 @@ import 'dart:ui' as ui; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; import 'package:flutter_map/src/layer/label.dart'; -import 'package:flutter_map/src/map/state.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; import 'package:latlong2/latlong.dart' hide Path; // conflict with Path from UI enum PolygonLabelPlacement { @@ -78,12 +78,12 @@ class PolygonLayer extends StatelessWidget { @override Widget build(BuildContext context) { - final map = FlutterMapState.of(context); + final map = MapCamera.of(context); final size = Size(map.size.x, map.size.y); final List pgons = polygonCulling ? polygons.where((p) { - return p.boundingBox.isOverlapping(map.bounds); + return p.boundingBox.isOverlapping(map.visibleBounds); }).toList() : polygons; @@ -97,10 +97,10 @@ class PolygonLayer extends StatelessWidget { class PolygonPainter extends CustomPainter { final List polygons; - final FlutterMapState map; + final MapCamera map; final LatLngBounds bounds; - PolygonPainter(this.polygons, this.map) : bounds = map.bounds; + PolygonPainter(this.polygons, this.map) : bounds = map.visibleBounds; int get hash { _hash ??= Object.hashAll(polygons); diff --git a/lib/src/layer/polyline_layer.dart b/lib/src/layer/polyline_layer.dart index 6d8702b5c..f43ba9a30 100644 --- a/lib/src/layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer.dart @@ -2,8 +2,8 @@ import 'dart:core'; import 'dart:ui' as ui; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/map/state.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; import 'package:latlong2/latlong.dart'; class Polyline { @@ -66,13 +66,13 @@ class PolylineLayer extends StatelessWidget { @override Widget build(BuildContext context) { - final map = FlutterMapState.of(context); + final map = MapCamera.of(context); return CustomPaint( painter: PolylinePainter( polylineCulling ? polylines - .where((p) => p.boundingBox.isOverlapping(map.bounds)) + .where((p) => p.boundingBox.isOverlapping(map.visibleBounds)) .toList() : polylines, map, @@ -86,10 +86,10 @@ class PolylineLayer extends StatelessWidget { class PolylinePainter extends CustomPainter { final List polylines; - final FlutterMapState map; + final MapCamera map; final LatLngBounds bounds; - PolylinePainter(this.polylines, this.map) : bounds = map.bounds; + PolylinePainter(this.polylines, this.map) : bounds = map.visibleBounds; int get hash { _hash ??= Object.hashAll(polylines); diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index a0a8b7c97..cfaca4e98 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/misc/point.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; +import 'package:flutter_map/src/misc/point.dart'; class Tile extends StatefulWidget { final TileImage tileImage; diff --git a/lib/src/layer/tile_layer/tile_bounds/tile_bounds.dart b/lib/src/layer/tile_layer/tile_bounds/tile_bounds.dart index 6cf9f8806..7d44013dd 100644 --- a/lib/src/layer/tile_layer/tile_bounds/tile_bounds.dart +++ b/lib/src/layer/tile_layer/tile_bounds/tile_bounds.dart @@ -1,8 +1,8 @@ -import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:flutter_map/src/geo/crs.dart'; import 'package:flutter_map/src/geo/latlng_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:latlong2/latlong.dart'; abstract class TileBounds { diff --git a/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart b/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart index b736d4f30..d89d856f7 100644 --- a/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart +++ b/lib/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart @@ -1,6 +1,6 @@ -import 'package:flutter_map/src/misc/point.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/misc/point.dart'; abstract class TileBoundsAtZoom { const TileBoundsAtZoom(); diff --git a/lib/src/layer/tile_layer/tile_coordinates.dart b/lib/src/layer/tile_layer/tile_coordinates.dart index 61929b6f4..35670fbc6 100644 --- a/lib/src/layer/tile_layer/tile_coordinates.dart +++ b/lib/src/layer/tile_layer/tile_coordinates.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/misc/point.dart'; class TileCoordinates extends CustomPoint { final int z; diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index c210dad1d..847cbf85b 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -1,5 +1,4 @@ import 'package:collection/collection.dart'; -import 'package:flutter_map/src/misc/point.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; @@ -7,6 +6,7 @@ import 'package:flutter_map/src/layer/tile_layer/tile_display.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/misc/point.dart'; typedef TileCreator = TileImage Function(TileCoordinates coordinates); diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index c34c17a48..0a45adbe0 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -4,30 +4,15 @@ import 'dart:math' as math; import 'package:collection/collection.dart' show MapEquality; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/src/misc/private/bounds.dart'; -import 'package:flutter_map/src/misc/point.dart'; -import 'package:flutter_map/src/misc/private/util.dart' as util; -import 'package:flutter_map/src/geo/crs.dart'; -import 'package:flutter_map/src/geo/latlng_bounds.dart'; -import 'package:flutter_map/src/gestures/map_events.dart'; +import 'package:flutter_map/plugin_api.dart'; import 'package:flutter_map/src/layer/tile_layer/tile.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_display.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image_manager.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/asset_tile_provider.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/file_providers/tile_provider_stub.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_tile_provider.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_update_transformer.dart'; -import 'package:flutter_map/src/map/state.dart'; +import 'package:flutter_map/src/misc/private/util.dart' as util; import 'package:http/retry.dart'; part 'tile_layer_options.dart'; @@ -301,7 +286,7 @@ class TileLayer extends StatefulWidget { } class _TileLayerState extends State with TickerProviderStateMixin { - bool _initializedFromMapState = false; + bool _initializedFromMapCamera = false; final TileImageManager _tileImageManager = TileImageManager(); late TileBounds _tileBounds; @@ -344,9 +329,9 @@ class _TileLayerState extends State with TickerProviderStateMixin { void didChangeDependencies() { super.didChangeDependencies(); - final mapState = FlutterMapState.maybeOf(context)!; + final camera = MapCamera.of(context); - final mapController = mapState.mapController; + final mapController = MapController.of(context); if (_mapControllerHashCode != mapController.hashCode) { _tileUpdateSubscription?.cancel(); @@ -354,34 +339,33 @@ class _TileLayerState extends State with TickerProviderStateMixin { _tileUpdateSubscription = mapController.mapEventStream .map((mapEvent) => TileUpdateEvent(mapEvent: mapEvent)) .transform(widget.tileUpdateTransformer) - .listen((event) => _onTileUpdateEvent(mapState, event)); + .listen((event) => _onTileUpdateEvent(event)); } bool reloadTiles = false; - if (!_initializedFromMapState || + if (!_initializedFromMapCamera || _tileBounds.shouldReplace( - mapState.options.crs, widget.tileSize, widget.tileBounds)) { + camera.crs, widget.tileSize, widget.tileBounds)) { reloadTiles = true; _tileBounds = TileBounds( - crs: mapState.options.crs, + crs: camera.crs, tileSize: widget.tileSize, latLngBounds: widget.tileBounds, ); } - if (!_initializedFromMapState || - _tileScaleCalculator.shouldReplace( - mapState.options.crs, widget.tileSize)) { + if (!_initializedFromMapCamera || + _tileScaleCalculator.shouldReplace(camera.crs, widget.tileSize)) { reloadTiles = true; _tileScaleCalculator = TileScaleCalculator( - crs: mapState.options.crs, + crs: camera.crs, tileSize: widget.tileSize, ); } - if (reloadTiles) _loadAndPruneInVisibleBounds(mapState); + if (reloadTiles) _loadAndPruneInVisibleBounds(camera); - _initializedFromMapState = true; + _initializedFromMapCamera = true; } @override @@ -437,7 +421,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (reloadTiles) { _tileImageManager.removeAll(widget.evictErrorTileStrategy); - _loadAndPruneInVisibleBounds(FlutterMapState.maybeOf(context)!); + _loadAndPruneInVisibleBounds(MapCamera.maybeOf(context)!); } else if (oldWidget.tileDisplay != widget.tileDisplay) { _tileImageManager.updateTileDisplay(widget.tileDisplay); } @@ -456,14 +440,14 @@ class _TileLayerState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - final map = FlutterMapState.of(context); + final map = MapCamera.of(context); if (_outsideZoomLimits(map.zoom.round())) return const SizedBox.shrink(); final tileZoom = _clampToNativeZoom(map.zoom); final tileBoundsAtZoom = _tileBounds.atZoom(tileZoom); final visibleTileRange = _tileRangeCalculator.calculate( - mapState: map, + camera: map, tileZoom: tileZoom, ); @@ -535,15 +519,13 @@ class _TileLayerState extends State with TickerProviderStateMixin { // Load and/or prune tiles according to the visible bounds of the [event] // center/zoom, or the current center/zoom if not specified. - void _onTileUpdateEvent(FlutterMapState mapState, TileUpdateEvent event) { - final zoom = event.loadZoomOverride ?? mapState.zoom; - final center = event.loadCenterOverride ?? mapState.center; - final tileZoom = _clampToNativeZoom(zoom); + void _onTileUpdateEvent(TileUpdateEvent event) { + final tileZoom = _clampToNativeZoom(event.zoom); final visibleTileRange = _tileRangeCalculator.calculate( - mapState: mapState, + camera: event.camera, tileZoom: tileZoom, - center: center, - viewingZoom: zoom, + center: event.center, + viewingZoom: event.zoom, ); if (event.load) { @@ -558,10 +540,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { } // Load new tiles in the visible bounds and prune those outside. - void _loadAndPruneInVisibleBounds(FlutterMapState mapState) { - final tileZoom = _clampToNativeZoom(mapState.zoom); + void _loadAndPruneInVisibleBounds(MapCamera camera) { + final tileZoom = _clampToNativeZoom(camera.zoom); final visibleTileRange = _tileRangeCalculator.calculate( - mapState: mapState, + camera: camera, tileZoom: tileZoom, ); diff --git a/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart b/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart index 420e9b256..2bf1c62ec 100644 --- a/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/network_tile_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter/rendering.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_image_provider.dart'; import 'package:http/http.dart'; import 'package:http/retry.dart'; diff --git a/lib/src/layer/tile_layer/tile_range.dart b/lib/src/layer/tile_layer/tile_range.dart index da142358a..fa4b4a789 100644 --- a/lib/src/layer/tile_layer/tile_range.dart +++ b/lib/src/layer/tile_layer/tile_range.dart @@ -1,8 +1,8 @@ import 'dart:math' as math; -import 'package:flutter_map/src/misc/private/bounds.dart'; -import 'package:flutter_map/src/misc/point.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:flutter_map/src/misc/point.dart'; +import 'package:flutter_map/src/misc/private/bounds.dart'; abstract class TileRange { final int zoom; diff --git a/lib/src/layer/tile_layer/tile_range_calculator.dart b/lib/src/layer/tile_layer/tile_range_calculator.dart index b823ef112..9ea140fd9 100644 --- a/lib/src/layer/tile_layer/tile_range_calculator.dart +++ b/lib/src/layer/tile_layer/tile_range_calculator.dart @@ -1,6 +1,6 @@ -import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; -import 'package:flutter_map/src/map/state.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; +import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:latlong2/latlong.dart'; class TileRangeCalculator { @@ -12,38 +12,38 @@ class TileRangeCalculator { /// viewing the map from the [viewingZoom] centered at the [center]. The /// resulting tile range is expanded by [panBuffer]. DiscreteTileRange calculate({ - // The map state used to calculate the bounds. - required FlutterMapState mapState, + // The map camera used to calculate the bounds. + required MapCamera camera, // The zoom level at which the bounds should be calculated. required int tileZoom, - // The center from which the map is viewed, defaults to [mapState.center]. + // The center from which the map is viewed, defaults to [camera.center]. LatLng? center, - // The zoom from which the map is viewed, defaults to [mapState.zoom]. + // The zoom from which the map is viewed, defaults to [camera.zoom]. double? viewingZoom, }) { return DiscreteTileRange.fromPixelBounds( zoom: tileZoom, tileSize: tileSize, pixelBounds: _calculatePixelBounds( - mapState, - center ?? mapState.center, - viewingZoom ?? mapState.zoom, + camera, + center ?? camera.center, + viewingZoom ?? camera.zoom, tileZoom, ), ); } Bounds _calculatePixelBounds( - FlutterMapState mapState, + MapCamera camera, LatLng center, double viewingZoom, int tileZoom, ) { final tileZoomDouble = tileZoom.toDouble(); - final scale = mapState.getZoomScale(viewingZoom, tileZoomDouble); + final scale = camera.getZoomScale(viewingZoom, tileZoomDouble); final pixelCenter = - mapState.project(center, tileZoomDouble).floor().toDoublePoint(); - final halfSize = mapState.size / (scale * 2); + camera.project(center, tileZoomDouble).floor().toDoublePoint(); + final halfSize = camera.size / (scale * 2); return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); } diff --git a/lib/src/layer/tile_layer/tile_update_event.dart b/lib/src/layer/tile_layer/tile_update_event.dart index a8aacb3df..46530effc 100644 --- a/lib/src/layer/tile_layer/tile_update_event.dart +++ b/lib/src/layer/tile_layer/tile_update_event.dart @@ -1,4 +1,5 @@ import 'package:flutter_map/src/gestures/map_events.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; import 'package:latlong2/latlong.dart'; /// Describes whether loading and/or pruning should occur and allows overriding @@ -18,6 +19,12 @@ class TileUpdateEvent { this.loadZoomOverride, }); + double get zoom => loadZoomOverride ?? mapEvent.camera.zoom; + + LatLng get center => loadCenterOverride ?? mapEvent.camera.center; + + MapCamera get camera => mapEvent.camera; + /// Returns a copy of this TileUpdateEvent with only pruning enabled and the /// loadCenterOverride/loadZoomOverride removed. TileUpdateEvent pruneOnly() => TileUpdateEvent( diff --git a/lib/src/layer/tile_layer/tile_update_transformer.dart b/lib/src/layer/tile_layer/tile_update_transformer.dart index c75c80373..da8dcfb4d 100644 --- a/lib/src/layer/tile_layer/tile_update_transformer.dart +++ b/lib/src/layer/tile_layer/tile_update_transformer.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'package:flutter_map/src/misc/private/util.dart'; import 'package:flutter_map/src/gestures/map_events.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_update_event.dart'; +import 'package:flutter_map/src/misc/private/util.dart'; /// Defines which [TileUpdateEvent]s should cause which [TileUpdateEvent]s and /// when diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart new file mode 100644 index 000000000..3b7ed9a97 --- /dev/null +++ b/lib/src/map/camera/camera.dart @@ -0,0 +1,361 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/src/geo/crs.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/map/inherited_model.dart'; +import 'package:flutter_map/src/map/options.dart'; +import 'package:flutter_map/src/misc/point.dart'; +import 'package:flutter_map/src/misc/private/bounds.dart'; +import 'package:latlong2/latlong.dart'; + +/// Describes the view of a map. This includes the size/zoom/position/crs as +/// well as the minimum/maximum zoom. This class is immutable, changes to the +/// map view may occur via the [MapController] or user interactions which will +/// result in a new [MapCamera] value. +class MapCamera { + // During Flutter startup the native platform resolution is not immediately + // available which can cause constraints to be zero before they are updated + // in a subsequent build to the actual constraints. We set the size to this + // impossible (negative) value initially and only change it once Flutter + // provides real constraints. + static const kImpossibleSize = CustomPoint(-1, -1); + + final Crs crs; + final double? minZoom; + final double? maxZoom; + + /// The [LatLng] which corresponds with the center of this camera. + final LatLng center; + + /// How far zoomed this camera is. + final double zoom; + + /// The rotation, in degrees, of the camera. See [rotationRad] for the same + /// value in radians. + final double rotation; + + @Deprecated( + 'Prefer `nonRotatedSize`. ' + 'This getter has been changed to fix the capitalization. ' + 'This getter is deprecated since v6.', + ) + CustomPoint get nonrotatedSize => nonRotatedSize; + + /// The size of the map view ignoring rotation. This will be the size of the + /// FlutterMap widget. + final CustomPoint nonRotatedSize; + + // Lazily calculated fields. + CustomPoint? _cameraSize; + Bounds? _pixelBounds; + LatLngBounds? _bounds; + CustomPoint? _pixelOrigin; + double? _rotationRad; + + @Deprecated( + 'Prefer `visibleBounds`. ' + 'This getter has been changed to clarify its meaning. ' + 'This getter is deprecated since v6.', + ) + LatLngBounds get bounds => visibleBounds; + + /// This is the [LatLngBounds] corresponding to four corners of this camera. + /// This takes rotation in to account. + LatLngBounds get visibleBounds => + _bounds ?? + (_bounds = LatLngBounds( + unproject(pixelBounds.bottomLeft, zoom), + unproject(pixelBounds.topRight, zoom), + )); + + /// The size of bounding box of this camera taking in to account its + /// rotation. When the rotation is zero this will equal [nonRotatedSize], + /// otherwise it will be the size of the rectangle which contains this + /// camera. + CustomPoint get size => + _cameraSize ?? + calculateRotatedSize( + rotation, + nonRotatedSize, + ); + + /// The offset of the top-left corner of the bounding rectangle of this + /// camera. This will not equal the offset of the top-left visible pixel when + /// the map is rotated. + CustomPoint get pixelOrigin => + _pixelOrigin ?? + (_pixelOrigin = (project(center, zoom) - size / 2.0).round()); + + /// The camera of the closest [FlutterMap] ancestor. If this is called from a + /// context with no [FlutterMap] ancestor null, is returned. + static MapCamera? maybeOf(BuildContext context) => + FlutterMapInheritedModel.maybeCameraOf(context); + + /// The camera of the closest [FlutterMap] ancestor. If this is called from a + /// context with no [FlutterMap] ancestor a [StateError] will be thrown. + static MapCamera of(BuildContext context) => + maybeOf(context) ?? + (throw StateError( + '`MapCamera.of()` should not be called outside a `FlutterMap` and its descendants')); + + /// Initializes [MapCamera] from the given [options] and with the + /// [nonRotatedSize] set to [kImpossibleSize]. + MapCamera.initialCamera(MapOptions options) + : crs = options.crs, + minZoom = options.minZoom, + maxZoom = options.maxZoom, + center = options.initialCenter, + zoom = options.initialZoom, + rotation = options.initialRotation, + nonRotatedSize = kImpossibleSize; + + // Create an instance of [MapCamera]. The [pixelOrigin], [bounds], and + // [pixelBounds] may be set if they are known already. Otherwise if left + // null they will be calculated lazily when they are used. + MapCamera({ + required this.crs, + required this.center, + required this.zoom, + required this.rotation, + required this.nonRotatedSize, + this.minZoom, + this.maxZoom, + CustomPoint? size, + Bounds? pixelBounds, + LatLngBounds? bounds, + CustomPoint? pixelOrigin, + double? rotationRad, + }) : _cameraSize = size ?? calculateRotatedSize(rotation, nonRotatedSize), + _pixelBounds = pixelBounds, + _bounds = bounds, + _pixelOrigin = pixelOrigin, + _rotationRad = rotationRad; + + /// Returns a new instance of [MapCamera] with the given [nonRotatedSize]. + MapCamera withNonRotatedSize(CustomPoint nonRotatedSize) { + if (nonRotatedSize == this.nonRotatedSize) return this; + + return MapCamera( + crs: crs, + center: center, + zoom: zoom, + rotation: rotation, + nonRotatedSize: nonRotatedSize, + minZoom: minZoom, + maxZoom: maxZoom, + rotationRad: _rotationRad, + ); + } + + /// Returns a new instance of [MapCamera] with the given [rotation]. + MapCamera withRotation(double rotation) { + if (rotation == this.rotation) return this; + + return MapCamera( + crs: crs, + center: center, + zoom: zoom, + nonRotatedSize: nonRotatedSize, + rotation: rotation, + minZoom: minZoom, + maxZoom: maxZoom, + ); + } + + /// Returns a new instance of [MapCamera] with the given [options]. + MapCamera withOptions(MapOptions options) { + if (options.crs == crs && + options.minZoom == minZoom && + options.maxZoom == maxZoom) { + return this; + } + + return MapCamera( + crs: options.crs, + minZoom: options.minZoom, + maxZoom: options.maxZoom, + center: center, + zoom: zoom, + rotation: rotation, + nonRotatedSize: nonRotatedSize, + size: _cameraSize, + rotationRad: _rotationRad, + ); + } + + /// Returns a new instance of [MapCamera] with the given [center]/[zoom]. + MapCamera withPosition({ + LatLng? center, + double? zoom, + }) => + MapCamera( + crs: crs, + minZoom: minZoom, + maxZoom: maxZoom, + center: center ?? this.center, + zoom: zoom ?? this.zoom, + rotation: rotation, + nonRotatedSize: nonRotatedSize, + size: _cameraSize, + rotationRad: _rotationRad, + ); + + /// Calculates the size of a bounding box which surrounds a box of size + /// [nonRotatedSize] which is rotated by [rotation]. + static CustomPoint calculateRotatedSize( + double rotation, + CustomPoint nonRotatedSize, + ) { + if (rotation == 0.0) return nonRotatedSize; + + final rotationRad = degToRadian(rotation); + final cosAngle = math.cos(rotationRad).abs(); + final sinAngle = math.sin(rotationRad).abs(); + final width = (nonRotatedSize.x * cosAngle) + (nonRotatedSize.y * sinAngle); + final height = + (nonRotatedSize.y * cosAngle) + (nonRotatedSize.x * sinAngle); + + return CustomPoint(width, height); + } + + /// The current rotation value in radians. This is calculated and cached when + /// it is first called. + double get rotationRad => _rotationRad ??= degToRadian(rotation); + + /// Calculates point value for the given [latLng] using this camera's + /// [crs] and [zoom] (or the provided [zoom]). + CustomPoint project(LatLng latlng, [double? zoom]) => + crs.latLngToPoint(latlng, zoom ?? this.zoom); + + /// Calculates the [LatLng] for the given [point] using this camera's + /// [crs] and [zoom] (or the provided [zoom]). + LatLng unproject(CustomPoint point, [double? zoom]) => + crs.pointToLatLng(point, zoom ?? this.zoom); + + LatLng layerPointToLatLng(CustomPoint point) => unproject(point); + + /// Calculates the scale for a zoom from [fromZoom] to [toZoom] using this + /// camera\s [crs]. + double getZoomScale(double toZoom, double fromZoom) => + crs.scale(toZoom) / crs.scale(fromZoom); + + /// Calculates the scale for this camera's [zoom]. + double getScaleZoom(double scale) => crs.zoom(scale * crs.scale(zoom)); + + /// Calculates the pixel bounds of this camera's [crs]. + Bounds? getPixelWorldBounds(double? zoom) => + crs.getProjectedBounds(zoom ?? this.zoom); + + /// Calculates the [Offset] from the [pos] to this camera's [pixelOrigin]. + Offset getOffsetFromOrigin(LatLng pos) { + final delta = project(pos) - pixelOrigin; + return Offset(delta.x, delta.y); + } + + /// Calculates the pixel origin of this [MapCamera] at the given + /// [center]/[zoom]. + CustomPoint getNewPixelOrigin(LatLng center, [double? zoom]) { + final halfSize = size / 2.0; + return (project(center, zoom) - halfSize).round(); + } + + /// Calculates the pixel bounds of this [MapCamera]. This value is cached. + Bounds get pixelBounds => + _pixelBounds ?? (_pixelBounds = pixelBoundsAtZoom(zoom)); + + /// Calculates the pixel bounds of this [MapCamera] at the given [zoom]. + Bounds pixelBoundsAtZoom(double zoom) { + CustomPoint halfSize = size / 2; + if (zoom != this.zoom) { + final scale = getZoomScale(this.zoom, zoom); + halfSize = size / (scale * 2); + } + final pixelCenter = project(center, zoom).floor().toDoublePoint(); + return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); + } + + // This will convert a latLng to a position that we could use with a widget + // outside of FlutterMap layer space. Eg using a Positioned Widget. + CustomPoint latLngToScreenPoint(LatLng latLng) { + final nonRotatedPixelOrigin = + (project(center, zoom) - nonRotatedSize / 2.0).round(); + + var point = crs.latLngToPoint(latLng, zoom); + + final mapCenter = crs.latLngToPoint(center, zoom); + + if (rotation != 0.0) { + point = rotatePoint(mapCenter, point, counterRotation: false); + } + + return point - nonRotatedPixelOrigin; + } + + LatLng pointToLatLng(CustomPoint localPoint) { + final localPointCenterDistance = CustomPoint( + (nonRotatedSize.x / 2) - localPoint.x, + (nonRotatedSize.y / 2) - localPoint.y, + ); + final mapCenter = crs.latLngToPoint(center, zoom); + + var point = mapCenter - localPointCenterDistance; + + if (rotation != 0.0) { + point = rotatePoint(mapCenter, point); + } + + return crs.pointToLatLng(point, zoom); + } + + // Sometimes we need to make allowances that a rotation already exists, so + // it needs to be reversed (pointToLatLng), and sometimes we want to use + // the same rotation to create a new position (latLngToScreenpoint). + // counterRotation just makes allowances this for this. + CustomPoint rotatePoint( + CustomPoint mapCenter, + CustomPoint point, { + bool counterRotation = true, + }) { + final counterRotationFactor = counterRotation ? -1 : 1; + + final m = Matrix4.identity() + ..translate(mapCenter.x, mapCenter.y) + ..rotateZ(rotationRad * counterRotationFactor) + ..translate(-mapCenter.x, -mapCenter.y); + + final tp = MatrixUtils.transformPoint(m, Offset(point.x, point.y)); + + return CustomPoint(tp.dx, tp.dy); + } + + /// Clamps the provided [zoom] to the range specified by [minZoom] and + /// [maxZoom], if set. + double clampZoom(double zoom) => zoom.clamp( + minZoom ?? double.negativeInfinity, + maxZoom ?? double.infinity, + ); + + LatLng offsetToCrs(Offset offset, [double? zoom]) { + final focalStartPt = project(center, zoom ?? this.zoom); + final point = + (offset.toCustomPoint() - (nonRotatedSize / 2.0)).rotate(rotationRad); + + final newCenterPt = focalStartPt + point; + return unproject(newCenterPt, zoom ?? this.zoom); + } + + // Calculate the center point which would keep the same point of the map + // visible at the given [cursorPos] with the zoom set to [zoom]. + LatLng focusedZoomCenter(CustomPoint cursorPos, double zoom) { + // Calculate offset of mouse cursor from viewport center + final viewCenter = nonRotatedSize / 2; + final offset = (cursorPos - viewCenter).rotate(rotationRad); + // Match new center coordinate to mouse cursor position + final scale = getZoomScale(zoom, this.zoom); + final newOffset = offset * (1.0 - 1.0 / scale); + final mapCenter = project(center); + final newCenter = unproject(mapCenter + newOffset); + return newCenter; + } +} diff --git a/lib/src/map/camera/camera_constraint.dart b/lib/src/map/camera/camera_constraint.dart new file mode 100644 index 000000000..f192eaa79 --- /dev/null +++ b/lib/src/map/camera/camera_constraint.dart @@ -0,0 +1,144 @@ +import 'dart:math' as math; + +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; +import 'package:flutter_map/src/map/camera/camera_fit.dart'; +import 'package:flutter_map/src/misc/point.dart'; +import 'package:latlong2/latlong.dart'; + +/// Describes a boundary for a [MapCamera], that cannot be exceeded by movement +/// +/// This separate from constraints that may be imposed by the chosen CRS. +/// +/// Positioning is handled by [CameraFit]. +abstract class CameraConstraint { + /// Describes a boundary for a [MapCamera], that cannot be exceeded by movement + /// + /// This separate from constraints that may be imposed by the chosen CRS. + /// + /// Positioning is handled by [CameraFit]. + const CameraConstraint(); + + /// Does not apply any constraint + const factory CameraConstraint.unconstrained() = UnconstrainedCamera._; + + /// Constrains the center coordinate of the camera to within [bounds] + /// + /// Areas outside of [bounds] are likely to be visible. To instead constrain + /// by the edges of the camera, use [CameraConstraint.contain]. + const factory CameraConstraint.containCenter({ + required LatLngBounds bounds, + }) = ContainCameraCenter._; + + /// Constrains the edges of the camera to within [bounds] + /// + /// To instead constrain the center coordinate of the camera to these bounds, + /// use [CameraConstraint.containCenter]. + const factory CameraConstraint.contain({ + required LatLngBounds bounds, + }) = ContainCamera._; + + /// Create a new constrained camera based off the current [camera] + /// + /// May return `null` if no appropriate camera could be generated by movement, + /// for example because the camera was zoomed too far out. + MapCamera? constrain(MapCamera camera); +} + +/// Does not apply any constraint to a [MapCamera] +/// +/// See [CameraConstraint] for more information. +class UnconstrainedCamera extends CameraConstraint { + const UnconstrainedCamera._(); + + @override + MapCamera constrain(MapCamera camera) => camera; +} + +/// Constrains the center coordinate of the camera to within [bounds] +/// +/// Areas outside of [bounds] are likely to be visible. To instead constrain +/// by the edges of the camera, use [ContainCamera]. +/// +/// See [CameraConstraint] for more information. +class ContainCameraCenter extends CameraConstraint { + const ContainCameraCenter._({required this.bounds}); + + 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, + ), + ), + ); + + @override + bool operator ==(Object other) { + return other is ContainCameraCenter && other.bounds == bounds; + } + + @override + int get hashCode => bounds.hashCode; +} + +/// Constrains the edges of the camera to within [bounds] +/// +/// To instead constrain the center coordinate of the camera to these bounds, +/// use [ContainCameraCenter]. +/// +/// See [CameraConstraint] for more information. +class ContainCamera extends CameraConstraint { + const ContainCamera._({required this.bounds}); + + final LatLngBounds bounds; + + @override + MapCamera? constrain(MapCamera camera) { + final testZoom = camera.zoom; + final testCenter = camera.center; + + final nePixel = camera.project(bounds.northEast, testZoom); + final swPixel = camera.project(bounds.southWest, testZoom); + + final halfSize = camera.size / 2; + + // Find the limits for the map center which would keep the camera within the + // [latLngBounds]. + final leftOkCenter = math.min(swPixel.x, nePixel.x) + halfSize.x; + final rightOkCenter = math.max(swPixel.x, nePixel.x) - halfSize.x; + final topOkCenter = math.min(swPixel.y, nePixel.y) + halfSize.y; + final botOkCenter = math.max(swPixel.y, nePixel.y) - halfSize.y; + + // Stop if we are zoomed out so far that the camera cannot be translated to + // stay within [latLngBounds]. + if (leftOkCenter > rightOkCenter || topOkCenter > botOkCenter) return null; + + final centerPix = camera.project(testCenter, testZoom); + final newCenterPix = CustomPoint( + centerPix.x.clamp(leftOkCenter, rightOkCenter), + centerPix.y.clamp(topOkCenter, botOkCenter), + ); + + if (newCenterPix == centerPix) return camera; + + return camera.withPosition( + center: camera.unproject(newCenterPix, testZoom), + ); + } + + @override + bool operator ==(Object other) { + return other is ContainCamera && other.bounds == bounds; + } + + @override + int get hashCode => bounds.hashCode; +} diff --git a/lib/src/map/camera/camera_fit.dart b/lib/src/map/camera/camera_fit.dart new file mode 100644 index 000000000..48c58d651 --- /dev/null +++ b/lib/src/map/camera/camera_fit.dart @@ -0,0 +1,452 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.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/misc/point.dart'; +import 'package:flutter_map/src/misc/private/bounds.dart'; +import 'package:latlong2/latlong.dart'; + +/// Describes a position for a [MapCamera] +/// +/// Constraints are handled by [CameraConstraint]. +abstract class CameraFit { + /// Describes a position for a [MapCamera] + /// + /// Constraints are handled by [CameraConstraint]. + const CameraFit(); + + /// Fits the [bounds] inside the camera + /// + /// For information about available options, see the documentation on the + /// appropriate properties. + const factory CameraFit.bounds({ + required LatLngBounds bounds, + EdgeInsets padding, + double? maxZoom, + bool forceIntegerZoomLevel, + }) = FitBounds._; + + /// Fits the camera inside the [bounds] + /// + /// For information about available options, see the documentation on the + /// appropriate properties. + const factory CameraFit.insideBounds({ + required LatLngBounds bounds, + EdgeInsets padding, + double? maxZoom, + bool forceIntegerZoomLevel, + }) = FitInsideBounds._; + + /// Fits the camera to the [coordinates], as closely as possible + /// + /// For information about available options, see the documentation on the + /// appropriate properties. + /// + /// Allows for more fine grained boundaries when the camera is rotated. See + /// https://github.com/fleaflet/flutter_map/pull/1549 for more information. + /// + /// [inside] is not supported due to lack of implementation. + const factory CameraFit.coordinates({ + required List coordinates, + EdgeInsets padding, + double? maxZoom, + bool forceIntegerZoomLevel, + }) = FitCoordinates._; + + /// Create a new fitted camera based off the current [camera] + MapCamera fit(MapCamera camera); +} + +class FitBounds extends CameraFit { + /// The bounds which the camera should contain once it is fitted. + final LatLngBounds bounds; + + /// Adds a constant/pixel-based padding to the normal fit. + /// + /// Defaults to [EdgeInsets.zero]. + final EdgeInsets padding; + + /// Limits the maximum zoom level of the resulting fit if set. + /// + /// Defaults to null. + final double? maxZoom; + + /// Whether the zoom level of the resulting fit should be rounded to the + /// nearest integer level. + /// + /// Defaults to `false`. + final bool forceIntegerZoomLevel; + + const FitBounds._({ + required this.bounds, + this.padding = EdgeInsets.zero, + this.maxZoom, + this.forceIntegerZoomLevel = false, + }); + + /// Returns a new [MapCamera] which fits this classes configuration. + @override + MapCamera fit(MapCamera camera) { + final paddingTL = CustomPoint(padding.left, padding.top); + final paddingBR = CustomPoint(padding.right, padding.bottom); + + final paddingTotalXY = paddingTL + paddingBR; + + var newZoom = _getBoundsZoom(camera, paddingTotalXY); + if (maxZoom != null) newZoom = math.min(maxZoom!, newZoom); + + final paddingOffset = (paddingBR - paddingTL) / 2; + final swPoint = camera.project(bounds.southWest, newZoom); + final nePoint = camera.project(bounds.northEast, newZoom); + + final CustomPoint projectedCenter; + if (camera.rotation != 0.0) { + final swPointRotated = swPoint.rotate(-camera.rotationRad); + final nePointRotated = nePoint.rotate(-camera.rotationRad); + final centerRotated = + (swPointRotated + nePointRotated) / 2 + paddingOffset; + + projectedCenter = centerRotated.rotate(camera.rotationRad); + } else { + projectedCenter = (swPoint + nePoint) / 2 + paddingOffset; + } + + final center = camera.unproject(projectedCenter, newZoom); + return camera.withPosition( + center: center, + zoom: newZoom, + ); + } + + double _getBoundsZoom( + MapCamera camera, + CustomPoint pixelPadding, + ) { + final min = camera.minZoom ?? 0.0; + final max = math.min( + camera.maxZoom ?? double.infinity, + maxZoom ?? double.infinity, + ); + final nw = bounds.northWest; + final se = bounds.southEast; + var size = camera.nonRotatedSize - pixelPadding; + // Prevent negative size which results in NaN zoom value later on in the calculation + size = CustomPoint(math.max(0, size.x), math.max(0, size.y)); + var boundsSize = Bounds( + camera.project(se, camera.zoom), + camera.project(nw, camera.zoom), + ).size; + if (camera.rotation != 0.0) { + final cosAngle = math.cos(camera.rotationRad).abs(); + final sinAngle = math.sin(camera.rotationRad).abs(); + boundsSize = CustomPoint( + (boundsSize.x * cosAngle) + (boundsSize.y * sinAngle), + (boundsSize.y * cosAngle) + (boundsSize.x * sinAngle), + ); + } + + final scaleX = size.x / boundsSize.x; + final scaleY = size.y / boundsSize.y; + final scale = math.min(scaleX, scaleY); + + var boundsZoom = camera.getScaleZoom(scale); + + if (forceIntegerZoomLevel) { + boundsZoom = boundsZoom.floorToDouble(); + } + + return math.max(min, math.min(max, boundsZoom)); + } +} + +class FitInsideBounds extends CameraFit { + /// The bounds which the camera should fit inside once it is fitted. + final LatLngBounds bounds; + + /// Adds a constant/pixel-based padding to the normal fit. + /// + /// Defaults to [EdgeInsets.zero]. + final EdgeInsets padding; + + /// Limits the maximum zoom level of the resulting fit if set. + /// + /// Defaults to null. + final double? maxZoom; + + /// Whether the zoom level of the resulting fit should be rounded to the + /// nearest integer level. + /// + /// Defaults to `false`. + final bool forceIntegerZoomLevel; + + const FitInsideBounds._({ + required this.bounds, + this.padding = EdgeInsets.zero, + this.maxZoom, + this.forceIntegerZoomLevel = false, + }); + + @override + MapCamera fit(MapCamera camera) { + final paddingTL = CustomPoint(padding.left, padding.top); + final paddingBR = CustomPoint(padding.right, padding.bottom); + final paddingTotalXY = paddingTL + paddingBR; + final paddingOffset = (paddingBR - paddingTL) / 2; + + final cameraSize = camera.nonRotatedSize - paddingTotalXY; + + final projectedBoundsSize = Bounds( + camera.project(bounds.southEast, camera.zoom), + camera.project(bounds.northWest, camera.zoom), + ).size; + + final scale = _rectInRotRectScale( + angleRad: camera.rotationRad, + smallRectHalfWidth: cameraSize.x / 2.0, + smallRectHalfHeight: cameraSize.y / 2.0, + bigRectHalfWidth: projectedBoundsSize.x / 2.0, + bigRectHalfHeight: projectedBoundsSize.y / 2.0, + ); + + var newZoom = camera.getScaleZoom(1.0 / scale); + + if (forceIntegerZoomLevel) { + newZoom = newZoom.ceilToDouble(); + } + + newZoom = math.max( + camera.minZoom ?? double.negativeInfinity, + math.min( + math.min(maxZoom ?? double.infinity, camera.maxZoom ?? double.infinity), + newZoom, + ), + ); + + final newCenter = _getCenter( + camera, + newZoom: newZoom, + paddingOffset: paddingOffset, + ); + + return camera.withPosition( + center: newCenter, + zoom: newZoom, + ); + } + + LatLng _getCenter( + MapCamera camera, { + required double newZoom, + required CustomPoint paddingOffset, + }) { + if (camera.rotation == 0.0) { + final swPoint = camera.project(bounds.southWest, newZoom); + final nePoint = camera.project(bounds.northEast, newZoom); + final projectedCenter = (swPoint + nePoint) / 2 + paddingOffset; + final newCenter = camera.unproject(projectedCenter, newZoom); + + return newCenter; + } + + // Handle rotation + final projectedCenter = camera.project(bounds.center, newZoom); + final rotatedCenter = projectedCenter.rotate(-camera.rotationRad); + final adjustedCenter = rotatedCenter + paddingOffset; + final derotatedAdjustedCenter = adjustedCenter.rotate(camera.rotationRad); + final newCenter = camera.unproject(derotatedAdjustedCenter, newZoom); + + return newCenter; + } + + static double _normalize(double value, double start, double end) { + final width = end - start; + final offsetValue = value - start; + + return (offsetValue - (offsetValue / width).floorToDouble() * width) + + start; + } + + /// Given two rectangles with their centers at the origin and an angle by + /// which the big rectangle is rotated, calculates the coefficient that would + /// be needed to scale the small rectangle such that it fits perfectly in the + /// larger rectangle while maintaining its aspect ratio. + /// + /// This algorithm has been adapted from https://stackoverflow.com/a/75907251 + static double _rectInRotRectScale({ + required double angleRad, + required double smallRectHalfWidth, + required double smallRectHalfHeight, + required double bigRectHalfWidth, + required double bigRectHalfHeight, + }) { + angleRad = _normalize(angleRad, 0, 2.0 * math.pi); + var kmin = double.infinity; + final quadrant = (2.0 * angleRad / math.pi).floor(); + if (quadrant.isOdd) { + final px = bigRectHalfWidth * math.cos(angleRad) + + bigRectHalfHeight * math.sin(angleRad); + final py = bigRectHalfWidth * math.sin(angleRad) - + bigRectHalfHeight * math.cos(angleRad); + final dx = -math.cos(angleRad); + final dy = -math.sin(angleRad); + + if (smallRectHalfWidth * dy - smallRectHalfHeight * dx != 0) { + var k = (px * dy - py * dx) / + (smallRectHalfWidth * dy - smallRectHalfHeight * dx); + if (quadrant >= 2) { + k = -k; + } + + if (k > 0) { + kmin = math.min(kmin, k); + } + } + + if (-smallRectHalfWidth * dx + smallRectHalfHeight * dy != 0) { + var k = (px * dx + py * dy) / + (-smallRectHalfWidth * dx + smallRectHalfHeight * dy); + if (quadrant >= 2) { + k = -k; + } + + if (k > 0) { + kmin = math.min(kmin, k); + } + } + + return kmin; + } else { + final px = bigRectHalfWidth * math.cos(angleRad) - + bigRectHalfHeight * math.sin(angleRad); + final py = bigRectHalfWidth * math.sin(angleRad) + + bigRectHalfHeight * math.cos(angleRad); + final dx = math.sin(angleRad); + final dy = -math.cos(angleRad); + if (smallRectHalfWidth * dy - smallRectHalfHeight * dx != 0) { + var k = (px * dy - py * dx) / + (smallRectHalfWidth * dy - smallRectHalfHeight * dx); + if (quadrant >= 2) { + k = -k; + } + + if (k > 0) { + kmin = math.min(kmin, k); + } + } + + if (-smallRectHalfWidth * dx + smallRectHalfHeight * dy != 0) { + var k = (px * dx + py * dy) / + (-smallRectHalfWidth * dx + smallRectHalfHeight * dy); + if (quadrant >= 2) { + k = -k; + } + + if (k > 0) { + kmin = math.min(kmin, k); + } + } + + return kmin; + } + } +} + +class FitCoordinates extends CameraFit { + /// The coordinates which the camera should contain once it is fitted. + final List coordinates; + + /// Adds a constant/pixel-based padding to the normal fit. + /// + /// Defaults to [EdgeInsets.zero]. + final EdgeInsets padding; + + /// Limits the maximum zoom level of the resulting fit if set. + /// + /// Defaults to null. + final double? maxZoom; + + /// Whether the zoom level of the resulting fit should be rounded to the + /// nearest integer level. + /// + /// Defaults to `false`. + final bool forceIntegerZoomLevel; + + const FitCoordinates._({ + required this.coordinates, + this.padding = EdgeInsets.zero, + this.maxZoom = double.infinity, + this.forceIntegerZoomLevel = false, + }); + + /// Returns a new [MapCamera] which fits this classes configuration. + @override + MapCamera fit(MapCamera camera) { + final paddingTL = CustomPoint(padding.left, padding.top); + final paddingBR = CustomPoint(padding.right, padding.bottom); + + final paddingTotalXY = paddingTL + paddingBR; + + var newZoom = _getCoordinatesZoom(camera, paddingTotalXY); + if (maxZoom != null) newZoom = math.min(maxZoom!, newZoom); + + final projectedPoints = [ + for (final coord in coordinates) camera.project(coord, newZoom) + ]; + + final rotatedPoints = + projectedPoints.map((point) => point.rotate(-camera.rotationRad)); + + final rotatedBounds = Bounds.containing(rotatedPoints); + + // Apply padding + final paddingOffset = (paddingBR - paddingTL) / 2; + final rotatedNewCenter = rotatedBounds.center + paddingOffset; + + // Undo the rotation + final unrotatedNewCenter = rotatedNewCenter.rotate(camera.rotationRad); + + final newCenter = camera.unproject(unrotatedNewCenter, newZoom); + + return camera.withPosition( + center: newCenter, + zoom: newZoom, + ); + } + + double _getCoordinatesZoom( + MapCamera camera, + CustomPoint pixelPadding, + ) { + final min = camera.minZoom ?? 0.0; + final max = math.min( + camera.maxZoom ?? double.infinity, + maxZoom ?? double.infinity, + ); + var size = camera.nonRotatedSize - pixelPadding; + // Prevent negative size which results in NaN zoom value later on in the calculation + size = CustomPoint(math.max(0, size.x), math.max(0, size.y)); + + final projectedPoints = [ + for (final coord in coordinates) camera.project(coord) + ]; + + final rotatedPoints = + projectedPoints.map((point) => point.rotate(-camera.rotationRad)); + final rotatedBounds = Bounds.containing(rotatedPoints); + + final boundsSize = rotatedBounds.size; + + final scaleX = size.x / boundsSize.x; + final scaleY = size.y / boundsSize.y; + final scale = math.min(scaleX, scaleY); + + var newZoom = camera.getScaleZoom(scale); + if (forceIntegerZoomLevel) { + newZoom = newZoom.floorToDouble(); + } + + return math.max(min, math.min(max, newZoom)); + } +} diff --git a/lib/src/map/inherited_model.dart b/lib/src/map/inherited_model.dart new file mode 100644 index 000000000..f11e0d2e6 --- /dev/null +++ b/lib/src/map/inherited_model.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; +import 'package:flutter_map/src/map/map_controller.dart'; +import 'package:flutter_map/src/map/options.dart'; + +/// Allows descendents of [FlutterMap] to access the [MapCamera], [MapOptions] +/// and [MapController]. Those classes provide of/maybeOf methods for users to +/// use, those methods call the relevant methods provided by this class. +/// +/// Using an [InheritedModel] means dependent widgets will only rebuild when +/// the aspect they reference is updated. +class FlutterMapInheritedModel extends InheritedModel<_FlutterMapAspect> { + final FlutterMapData data; + + FlutterMapInheritedModel({ + super.key, + required MapCamera camera, + required MapController controller, + required MapOptions options, + required super.child, + }) : data = FlutterMapData( + camera: camera, + controller: controller, + options: options, + ); + + static FlutterMapData? _maybeOf( + BuildContext context, [ + _FlutterMapAspect? aspect, + ]) => + InheritedModel.inheritFrom(context, + aspect: aspect) + ?.data; + + static MapCamera? maybeCameraOf(BuildContext context) => + _maybeOf(context, _FlutterMapAspect.camera)?.camera; + + static MapController? maybeControllerOf(BuildContext context) => + _maybeOf(context, _FlutterMapAspect.controller)?.controller; + + static MapOptions? maybeOptionsOf(BuildContext context) => + _maybeOf(context, _FlutterMapAspect.options)?.options; + + @override + bool updateShouldNotify(FlutterMapInheritedModel oldWidget) => + data != oldWidget.data; + + @override + bool updateShouldNotifyDependent( + covariant FlutterMapInheritedModel oldWidget, + Set dependencies, + ) { + for (final dependency in dependencies) { + if (dependency is _FlutterMapAspect) { + switch (dependency) { + case _FlutterMapAspect.camera: + if (data.camera != oldWidget.data.camera) return true; + case _FlutterMapAspect.controller: + if (data.controller != oldWidget.data.controller) return true; + case _FlutterMapAspect.options: + if (data.options != oldWidget.data.options) return true; + } + } + } + + return false; + } +} + +class FlutterMapData { + final MapCamera camera; + final MapController controller; + final MapOptions options; + + const FlutterMapData({ + required this.camera, + required this.controller, + required this.options, + }); +} + +enum _FlutterMapAspect { camera, controller, options } diff --git a/lib/src/map/internal_controller.dart b/lib/src/map/internal_controller.dart new file mode 100644 index 000000000..513b1ee90 --- /dev/null +++ b/lib/src/map/internal_controller.dart @@ -0,0 +1,472 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_map/plugin_api.dart'; +import 'package:flutter_map/src/gestures/flutter_map_interactive_viewer.dart'; +import 'package:flutter_map/src/map/map_controller_impl.dart'; +import 'package:latlong2/latlong.dart'; + +// This controller is for internal use. All updates to the state should be done +// by calling methods of this class to ensure consistency. +class FlutterMapInternalController extends ValueNotifier<_InternalState> { + late final FlutterMapInteractiveViewerState _interactiveViewerState; + late MapControllerImpl _mapControllerImpl; + + FlutterMapInternalController(MapOptions options) + : super( + _InternalState( + options: options, + camera: MapCamera.initialCamera(options), + ), + ); + + // Link the viewer state with the controller. This should be done once when + // the FlutterMapInteractiveViewerState is initialized. + set interactiveViewerState( + FlutterMapInteractiveViewerState interactiveViewerState, + ) => + _interactiveViewerState = interactiveViewerState; + + MapOptions get options => value.options; + MapCamera get camera => value.camera; + + void linkMapController(MapControllerImpl mapControllerImpl) { + _mapControllerImpl = mapControllerImpl; + _mapControllerImpl.internalController = this; + } + + /// This setter should only be called in this class or within tests. Changes + /// to the [FlutterMapInternalState] should be done via methods in this class. + @visibleForTesting + @override + // ignore: library_private_types_in_public_api + set value(_InternalState value) => super.value = value; + + // Note: All named parameters are required to prevent inconsistent default + // values since this method can be called by MapController which declares + // defaults. + bool move( + LatLng newCenter, + double newZoom, { + required Offset offset, + required bool hasGesture, + required MapEventSource source, + required String? id, + }) { + // Algorithm thanks to https://github.com/tlserver/flutter_map_location_marker + if (offset != Offset.zero) { + final newPoint = camera.project(newCenter, newZoom); + newCenter = camera.unproject( + camera.rotatePoint( + newPoint, + newPoint - CustomPoint(offset.dx, offset.dy), + ), + newZoom, + ); + } + + MapCamera? newCamera = camera.withPosition( + center: newCenter, + zoom: camera.clampZoom(newZoom), + ); + + newCamera = options.cameraConstraint.constrain(newCamera); + if (newCamera == null || + (newCamera.center == camera.center && newCamera.zoom == camera.zoom)) { + return false; + } + + final oldCamera = camera; + value = value.withMapCamera(newCamera); + + final movementEvent = MapEventWithMove.fromSource( + oldCamera: oldCamera, + camera: camera, + hasGesture: hasGesture, + source: source, + id: id, + ); + if (movementEvent != null) _emitMapEvent(movementEvent); + + options.onPositionChanged?.call( + MapPosition( + center: newCenter, + bounds: camera.visibleBounds, + zoom: newZoom, + hasGesture: hasGesture, + ), + hasGesture, + ); + + return true; + } + + // Note: All named parameters are required to prevent inconsistent default + // values since this method can be called by MapController which declares + // defaults. + bool rotate( + double newRotation, { + required bool hasGesture, + required MapEventSource source, + required String? id, + }) { + if (newRotation != camera.rotation) { + final newCamera = options.cameraConstraint.constrain( + camera.withRotation(newRotation), + ); + if (newCamera == null) return false; + + final oldCamera = camera; + + // Update camera then emit events and callbacks + value = value.withMapCamera(newCamera); + + _emitMapEvent( + MapEventRotate( + id: id, + source: source, + oldCamera: oldCamera, + camera: camera, + ), + ); + return true; + } + + return false; + } + + // Note: All named parameters are required to prevent inconsistent default + // values since this method can be called by MapController which declares + // defaults. + MoveAndRotateResult rotateAroundPoint( + double degree, { + required CustomPoint? point, + required Offset? offset, + required bool hasGesture, + required MapEventSource source, + required String? id, + }) { + if (point != null && offset != null) { + throw ArgumentError('Only one of `point` or `offset` may be non-null'); + } + if (point == null && offset == null) { + throw ArgumentError('One of `point` or `offset` must be non-null'); + } + + if (degree == camera.rotation) { + return MoveAndRotateResult(false, false); + } + + if (offset == Offset.zero) { + return MoveAndRotateResult( + true, + rotate( + degree, + hasGesture: hasGesture, + source: source, + id: id, + ), + ); + } + + final rotationDiff = degree - camera.rotation; + final rotationCenter = camera.project(camera.center) + + (point != null + ? (point - (camera.nonRotatedSize / 2.0)) + : CustomPoint(offset!.dx, offset.dy)) + .rotate(camera.rotationRad); + + return MoveAndRotateResult( + move( + camera.unproject( + rotationCenter + + (camera.project(camera.center) - rotationCenter) + .rotate(degToRadian(rotationDiff)), + ), + camera.zoom, + offset: Offset.zero, + hasGesture: hasGesture, + source: source, + id: id, + ), + rotate( + camera.rotation + rotationDiff, + hasGesture: hasGesture, + source: source, + id: id, + ), + ); + } + + // Note: All named parameters are required to prevent inconsistent default + // values since this method can be called by MapController which declares + // defaults. + MoveAndRotateResult moveAndRotate( + LatLng newCenter, + double newZoom, + double newRotation, { + required Offset offset, + required bool hasGesture, + required MapEventSource source, + required String? id, + }) => + MoveAndRotateResult( + move( + newCenter, + newZoom, + offset: offset, + hasGesture: hasGesture, + source: source, + id: id, + ), + rotate(newRotation, id: id, source: source, hasGesture: hasGesture), + ); + + // Note: All named parameters are required to prevent inconsistent default + // values since this method can be called by MapController which declares + // defaults. + bool fitCamera( + CameraFit cameraFit, { + required Offset offset, + }) { + final fitted = cameraFit.fit(camera); + + return move( + fitted.center, + fitted.zoom, + offset: offset, + hasGesture: false, + source: MapEventSource.fitCamera, + id: null, + ); + } + + bool setNonRotatedSizeWithoutEmittingEvent( + CustomPoint nonRotatedSize, + ) { + if (nonRotatedSize != MapCamera.kImpossibleSize && + nonRotatedSize != camera.nonRotatedSize) { + value = value.withMapCamera(camera.withNonRotatedSize(nonRotatedSize)); + return true; + } + + return false; + } + + void setOptions(MapOptions newOptions) { + assert( + newOptions != value.options, + 'Should not update options unless they change', + ); + + final newCamera = camera.withOptions(newOptions); + + assert( + newOptions.cameraConstraint.constrain(newCamera) == newCamera, + 'MapCamera is no longer within the cameraConstraint after an option change.', + ); + + if (options.interactionOptions != newOptions.interactionOptions) { + _interactiveViewerState.updateGestures( + options.interactionOptions, + newOptions.interactionOptions, + ); + } + + value = _InternalState( + options: newOptions, + camera: newCamera, + ); + } + + // 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.toCustomPoint(); + final newCenter = camera.unproject(newCenterPt); + + move( + newCenter, + camera.zoom, + offset: Offset.zero, + hasGesture: true, + source: source, + id: null, + ); + } + + // 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, + ), + ); + } + + void tapped( + MapEventSource source, + TapPosition tapPosition, + LatLng position, + ) { + options.onTap?.call(tapPosition, position); + _emitMapEvent( + MapEventTap( + tapPosition: position, + camera: camera, + source: source, + ), + ); + } + + void secondaryTapped( + MapEventSource source, + TapPosition tapPosition, + LatLng position, + ) { + options.onSecondaryTap?.call(tapPosition, position); + _emitMapEvent( + MapEventSecondaryTap( + tapPosition: position, + camera: camera, + source: source, + ), + ); + } + + 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( + MapEventNonRotatedSizeChange( + source: MapEventSource.nonRotatedSizeChange, + oldCamera: oldCamera, + camera: newCamera, + ), + ); + } + + void _emitMapEvent(MapEvent event) { + if (event.source == MapEventSource.mapController && event is MapEventMove) { + _interactiveViewerState.interruptAnimatedMovement(event); + } + + options.onMapEvent?.call(event); + + _mapControllerImpl.mapEventSink.add(event); + } +} + +class _InternalState { + final MapCamera camera; + final MapOptions options; + + const _InternalState({ + required this.options, + required this.camera, + }); + + _InternalState withMapCamera(MapCamera camera) => _InternalState( + options: options, + camera: camera, + ); +} diff --git a/lib/src/map/controller.dart b/lib/src/map/map_controller.dart similarity index 61% rename from lib/src/map/controller.dart rename to lib/src/map/map_controller.dart index 9a481b4ed..e29cecc0d 100644 --- a/lib/src/map/controller.dart +++ b/lib/src/map/map_controller.dart @@ -1,10 +1,17 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/map/state.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/gestures/map_events.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; +import 'package:flutter_map/src/map/camera/camera_fit.dart'; +import 'package:flutter_map/src/map/inherited_model.dart'; +import 'package:flutter_map/src/map/map_controller_impl.dart'; +import 'package:flutter_map/src/misc/center_zoom.dart'; +import 'package:flutter_map/src/misc/fit_bounds_options.dart'; +import 'package:flutter_map/src/misc/move_and_rotate_result.dart'; +import 'package:flutter_map/src/misc/point.dart'; import 'package:latlong2/latlong.dart'; -import 'package:meta/meta.dart'; /// Controller to programmatically interact with [FlutterMap], such as /// controlling it and accessing some of its properties. @@ -20,7 +27,24 @@ abstract class MapController { /// instance. /// /// Factory constructor redirects to underlying implementation's constructor. - factory MapController() = MapControllerImpl._; + factory MapController() = MapControllerImpl; + + /// The controller for the closest [FlutterMap] ancestor. If this is called + /// from a context with no [FlutterMap] ancestor a [StateError] will be + /// thrown. + static MapController? maybeOf(BuildContext context) => + FlutterMapInheritedModel.maybeControllerOf(context); + + /// The controller for the closest [FlutterMap] ancestor. If this is called + /// from a context with no [FlutterMap] ancestor a [StateError] will be + /// thrown. + static MapController of(BuildContext context) => + maybeOf(context) ?? + (throw StateError( + '`MapController.of()` should not be called outside a `FlutterMap` and its children')); + + /// [Stream] of all emitted [MapEvent]s + Stream get mapEventStream; /// Moves and zooms the map to a [center] and [zoom] level /// @@ -39,7 +63,7 @@ abstract class MapController { /// through [MapEventCallback]s, such as [MapOptions.onMapEvent]), unless /// the move failed because (after adjustment when necessary): /// * [center] and [zoom] are equal to the current values - /// * [center] is out of bounds & [MapOptions.slideOnBoundaries] isn't enabled + /// * [center] [MapOptions.cameraConstraint] does not allow the movement. bool move( LatLng center, double zoom, { @@ -74,8 +98,8 @@ abstract class MapController { /// pixels), where `Offset(0,0)` is the top-left of the map widget, and the /// bottom right is `Offset(mapWidth, mapHeight)`. /// * [offset]: allows rotation around a screen-based offset (in normal logical - /// pixels) from the map's [center]. For example, `Offset(100, 100)` will mean - /// the point is the 100px down & 100px right from the [center]. + /// pixels) from the map's center. For example, `Offset(100, 100)` will mean + /// the point is the 100px down & 100px right from the center. /// /// May cause glitchy movement if rotated against the map's bounds. /// @@ -110,30 +134,71 @@ abstract class MapController { String? id, }); + /// Move and zoom the map to fit [cameraFit]. + /// + /// For information about the return value and emitted events, see [move]'s + /// documentation. + bool fitCamera(CameraFit cameraFit); + + /// Current [MapCamera]. Accessing the camera from this getter is an + /// anti-pattern. It is preferable to use [MapCamera.of(context)] in a child + /// widget of FlutterMap. + MapCamera get camera; + /// Move and zoom the map to perfectly fit [bounds], with additional /// configurable [options] /// /// For information about return value meaning and emitted events, see [move]'s /// documentation. - bool fitBounds(LatLngBounds bounds, {FitBoundsOptions? options}); + @Deprecated( + 'Prefer `fitCamera` with a CameraFit.bounds() instead. ' + 'This method has been changed to use the new `CameraFit` classes which allows different kinds of fit. ' + 'This method is deprecated since v6.', + ) + bool fitBounds( + LatLngBounds bounds, { + FitBoundsOptions options = + const FitBoundsOptions(padding: EdgeInsets.all(12)), + }); /// Calculates the appropriate center and zoom level for the map to perfectly /// fit [bounds], with additional configurable [options] /// /// Does not move/zoom the map: see [fitBounds]. + @Deprecated( + 'Prefer `CameraFit.bounds(bounds: bounds).fit(controller.camera)`. ' + 'This method is replaced by applying a CameraFit to the MapCamera. ' + 'This method is deprecated since v6.', + ) CenterZoom centerZoomFitBounds( LatLngBounds bounds, { - FitBoundsOptions? options, + FitBoundsOptions options = + const FitBoundsOptions(padding: EdgeInsets.all(12)), }); /// Convert a screen point (x/y) to its corresponding map coordinate (lat/lng), /// based on the map's current properties + @Deprecated( + 'Prefer `controller.camera.pointToLatLng()`. ' + 'This method is now accessible via the camera. ' + 'This method is deprecated since v6.', + ) LatLng pointToLatLng(CustomPoint screenPoint); /// Convert a map coordinate (lat/lng) to its corresponding screen point (x/y), /// based on the map's current screen positioning + @Deprecated( + 'Prefer `controller.camera.latLngToScreenPoint()`. ' + 'This method is now accessible via the camera. ' + 'This method is deprecated since v6.', + ) CustomPoint latLngToScreenPoint(LatLng mapCoordinate); + @Deprecated( + 'Prefer `controller.camera.rotatePoint()`. ' + 'This method is now accessible via the camera. ' + 'This method is deprecated since v6.', + ) CustomPoint rotatePoint( CustomPoint mapCenter, CustomPoint point, { @@ -141,155 +206,37 @@ abstract class MapController { }); /// Current center coordinates + @Deprecated( + 'Prefer `controller.camera.center`. ' + 'This getter is now accessible via the camera. ' + 'This getter is deprecated since v6.', + ) LatLng get center; /// Current outer points/boundaries coordinates + @Deprecated( + 'Prefer `controller.camera.visibleBounds`. ' + 'This getter is now accessible via the camera. ' + 'This getter is deprecated since v6.', + ) LatLngBounds? get bounds; /// Current zoom level + @Deprecated( + 'Prefer `controller.camera.zoom`. ' + 'This getter is now accessible via the camera. ' + 'This getter is deprecated since v6.', + ) double get zoom; /// Current rotation in degrees, where 0° is North + @Deprecated( + 'Prefer `controller.camera.rotation`. ' + 'This getter is now accessible via the camera. ' + 'This getter is deprecated since v6.', + ) double get rotation; - /// [Stream] of all emitted [MapEvent]s - Stream get mapEventStream; - - /// Underlying [StreamSink] of [mapEventStream] - /// - /// Usually prefer to use [mapEventStream]. - StreamSink get mapEventSink; - - /// Immediately change the internal map state - /// - /// Not recommended for external usage. - set state(FlutterMapState state); - - /// Dispose of this controller by closing the [mapEventStream]'s - /// [StreamController] - /// - /// Not recommended for external usage. + /// Dispose of this controller. void dispose(); } - -@internal -class MapControllerImpl implements MapController { - MapControllerImpl._(); - - @override - bool move( - LatLng center, - double zoom, { - Offset offset = Offset.zero, - String? id, - }) => - _state.move( - center, - zoom, - offset: offset, - id: id, - source: MapEventSource.mapController, - ); - - @override - bool rotate(double degree, {String? id}) => - _state.rotate(degree, id: id, source: MapEventSource.mapController); - - @override - MoveAndRotateResult rotateAroundPoint( - double degree, { - CustomPoint? point, - Offset? offset, - String? id, - }) => - _state.rotateAroundPoint( - degree, - point: point, - offset: offset, - id: id, - source: MapEventSource.mapController, - ); - - @override - MoveAndRotateResult moveAndRotate( - LatLng center, - double zoom, - double degree, { - String? id, - }) => - _state.moveAndRotate( - center, - zoom, - degree, - source: MapEventSource.mapController, - id: id, - ); - - @override - bool fitBounds( - LatLngBounds bounds, { - FitBoundsOptions? options = - const FitBoundsOptions(padding: EdgeInsets.all(12)), - }) => - _state.fitBounds(bounds, options!); - - @override - CenterZoom centerZoomFitBounds( - LatLngBounds bounds, { - FitBoundsOptions? options = - const FitBoundsOptions(padding: EdgeInsets.all(12)), - }) => - _state.centerZoomFitBounds(bounds, options!); - - @override - LatLng pointToLatLng(CustomPoint localPoint) => - _state.pointToLatLng(localPoint); - - @override - CustomPoint latLngToScreenPoint(LatLng latLng) => - _state.latLngToScreenPoint(latLng); - - @override - CustomPoint rotatePoint( - CustomPoint mapCenter, - CustomPoint point, { - bool counterRotation = true, - }) => - _state.rotatePoint( - mapCenter.toDoublePoint(), - point.toDoublePoint(), - counterRotation: counterRotation, - ); - - @override - LatLng get center => _state.center; - - @override - LatLngBounds? get bounds => _state.bounds; - - @override - double get zoom => _state.zoom; - - @override - double get rotation => _state.rotation; - - final _mapEventStreamController = StreamController.broadcast(); - - @override - Stream get mapEventStream => _mapEventStreamController.stream; - - @override - StreamSink get mapEventSink => _mapEventStreamController.sink; - - late FlutterMapState _state; - - @override - set state(FlutterMapState state) { - _state = state; - } - - @override - void dispose() { - _mapEventStreamController.close(); - } -} diff --git a/lib/src/map/map_controller_impl.dart b/lib/src/map/map_controller_impl.dart new file mode 100644 index 000000000..889cace42 --- /dev/null +++ b/lib/src/map/map_controller_impl.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/gestures/map_events.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; +import 'package:flutter_map/src/map/camera/camera_fit.dart'; +import 'package:flutter_map/src/map/internal_controller.dart'; +import 'package:flutter_map/src/map/map_controller.dart'; +import 'package:flutter_map/src/misc/center_zoom.dart'; +import 'package:flutter_map/src/misc/fit_bounds_options.dart'; +import 'package:flutter_map/src/misc/move_and_rotate_result.dart'; +import 'package:flutter_map/src/misc/point.dart'; +import 'package:latlong2/latlong.dart'; + +/// Implements [MapController] whilst exposing methods for internal use which +/// should not be visible to the user (e.g. for setting the current camera or +/// linking the internal controller). +class MapControllerImpl implements MapController { + late FlutterMapInternalController _internalController; + final _mapEventStreamController = StreamController.broadcast(); + + MapControllerImpl(); + + set internalController(FlutterMapInternalController internalController) { + _internalController = internalController; + } + + StreamSink get mapEventSink => _mapEventStreamController.sink; + + @override + Stream get mapEventStream => _mapEventStreamController.stream; + + @override + bool move( + LatLng center, + double zoom, { + Offset offset = Offset.zero, + String? id, + }) => + _internalController.move( + center, + zoom, + offset: offset, + hasGesture: false, + source: MapEventSource.mapController, + id: id, + ); + + @override + bool rotate(double degree, {String? id}) => _internalController.rotate( + degree, + hasGesture: false, + source: MapEventSource.mapController, + id: id, + ); + + @override + MoveAndRotateResult rotateAroundPoint( + double degree, { + CustomPoint? point, + Offset? offset, + String? id, + }) => + _internalController.rotateAroundPoint( + degree, + point: point, + offset: offset, + hasGesture: false, + source: MapEventSource.mapController, + id: id, + ); + + @override + MoveAndRotateResult moveAndRotate( + LatLng center, + double zoom, + double degree, { + String? id, + }) => + _internalController.moveAndRotate( + center, + zoom, + degree, + offset: Offset.zero, + hasGesture: false, + source: MapEventSource.mapController, + id: id, + ); + + @override + bool fitCamera(CameraFit cameraFit) => _internalController.fitCamera( + cameraFit, + offset: Offset.zero, + ); + + @override + MapCamera get camera => _internalController.camera; + + @override + @Deprecated( + 'Prefer `fitCamera` with a CameraFit.bounds() or CameraFit.insideBounds() instead. ' + 'This method has been changed to use the new `CameraFit` classes which allows different kinds of fit. ' + 'This method is deprecated since v6.', + ) + bool fitBounds( + LatLngBounds bounds, { + FitBoundsOptions options = + const FitBoundsOptions(padding: EdgeInsets.all(12)), + }) => + fitCamera( + options.inside + ? CameraFit.insideBounds( + bounds: bounds, + padding: options.padding, + maxZoom: options.maxZoom, + forceIntegerZoomLevel: options.forceIntegerZoomLevel, + ) + : CameraFit.bounds( + bounds: bounds, + padding: options.padding, + maxZoom: options.maxZoom, + forceIntegerZoomLevel: options.forceIntegerZoomLevel, + ), + ); + + @override + @Deprecated( + 'Prefer `CameraFit.bounds(bounds: bounds).fit(controller.camera)` or `CameraFit.insideBounds(bounds: bounds).fit(controller.camera)`. ' + 'This method is replaced by applying a CameraFit to the MapCamera. ' + 'This method is deprecated since v6.', + ) + CenterZoom centerZoomFitBounds( + LatLngBounds bounds, { + FitBoundsOptions options = + const FitBoundsOptions(padding: EdgeInsets.all(12)), + }) { + final cameraFit = options.inside + ? CameraFit.insideBounds( + bounds: bounds, + padding: options.padding, + maxZoom: options.maxZoom, + forceIntegerZoomLevel: options.forceIntegerZoomLevel, + ) + : CameraFit.bounds( + bounds: bounds, + padding: options.padding, + maxZoom: options.maxZoom, + forceIntegerZoomLevel: options.forceIntegerZoomLevel, + ); + + final fittedState = cameraFit.fit(camera); + return CenterZoom( + center: fittedState.center, + zoom: fittedState.zoom, + ); + } + + @override + @Deprecated( + 'Prefer `controller.camera.pointToLatLng()`. ' + 'This method is now accessible via the camera. ' + 'This method is deprecated since v6.', + ) + LatLng pointToLatLng(CustomPoint screenPoint) => + camera.pointToLatLng(screenPoint); + + @override + @Deprecated( + 'Prefer `controller.camera.latLngToScreenPoint()`. ' + 'This method is now accessible via the camera. ' + 'This method is deprecated since v6.', + ) + CustomPoint latLngToScreenPoint(LatLng mapCoordinate) => + camera.latLngToScreenPoint(mapCoordinate); + + @override + @Deprecated( + 'Prefer `controller.camera.rotatePoint()`. ' + 'This method is now accessible via the camera. ' + 'This method is deprecated since v6.', + ) + CustomPoint rotatePoint( + CustomPoint mapCenter, + CustomPoint point, { + bool counterRotation = true, + }) => + camera.rotatePoint( + mapCenter.toDoublePoint(), + point.toDoublePoint(), + counterRotation: counterRotation, + ); + + @override + @Deprecated( + 'Prefer `controller.camera.center`. ' + 'This getter is now accessible via the camera. ' + 'This getter is deprecated since v6.', + ) + LatLng get center => camera.center; + + @override + @Deprecated( + 'Prefer `controller.camera.visibleBounds`. ' + 'This getter is now accessible via the camera. ' + 'This getter is deprecated since v6.', + ) + LatLngBounds? get bounds => camera.visibleBounds; + + @override + @Deprecated( + 'Prefer `controller.camera.zoom`. ' + 'This getter is now accessible via the camera. ' + 'This getter is deprecated since v6.', + ) + double get zoom => camera.zoom; + + @override + @Deprecated( + 'Prefer `controller.camera.rotation`. ' + 'This getter is now accessible via the camera. ' + 'This getter is deprecated since v6.', + ) + double get rotation => camera.rotation; + + @override + void dispose() { + _mapEventStreamController.close(); + } +} diff --git a/lib/src/map/options.dart b/lib/src/map/options.dart index e5a6ae14f..de92f94ea 100644 --- a/lib/src/map/options.dart +++ b/lib/src/map/options.dart @@ -1,31 +1,348 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; -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/map_events.dart'; +import 'package:flutter_map/src/gestures/multi_finger_gesture.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/inherited_model.dart'; +import 'package:flutter_map/src/misc/fit_bounds_options.dart'; +import 'package:flutter_map/src/misc/position.dart'; +import 'package:flutter_map/src/misc/private/positioned_tap_detector_2.dart'; import 'package:latlong2/latlong.dart'; -/// Allows you to provide your map's starting properties for [zoom], [rotation] -/// and [center]. Alternatively you can provide [bounds] instead of [center]. -/// If both, [center] and [bounds] are provided, bounds will take preference -/// over [center]. -/// Zoom, pan boundary and interactivity constraints can be specified here too. -/// -/// Callbacks for [onTap], [onSecondaryTap], [onLongPress] and -/// [onPositionChanged] can be registered here. -/// -/// Through [crs] the Coordinate Reference System can be -/// defined, it defaults to [Epsg3857]. -/// -/// Checks if a coordinate is outside of the map's -/// defined boundaries. -/// -/// If you download offline tiles dynamically, you can set [adaptiveBoundaries] -/// to true (make sure to pass [screenSize] and an external [controller]), which -/// will enforce panning/zooming to ensure there is never a need to display -/// tiles outside the boundaries set by [swPanBoundary] and [nePanBoundary]. +typedef MapEventCallback = void Function(MapEvent); + +typedef TapCallback = void Function(TapPosition tapPosition, LatLng point); +typedef LongPressCallback = void Function( + TapPosition tapPosition, + LatLng point, +); +typedef PointerDownCallback = void Function( + PointerDownEvent event, + LatLng point, +); +typedef PointerUpCallback = void Function(PointerUpEvent event, LatLng point); +typedef PointerCancelCallback = void Function( + PointerCancelEvent event, + LatLng point, +); +typedef PointerHoverCallback = void Function( + PointerHoverEvent event, + LatLng point, +); + class MapOptions { + /// The Coordinate Reference System, defaults to [Epsg3857]. final Crs crs; - final double zoom; - final double rotation; + + /// The center when the map is first loaded. If [initialCameraFit] is defined + /// this has no effect. + final LatLng initialCenter; + + /// The zoom when the map is first loaded. If [initialCameraFit] is defined + /// this has no effect. + final double initialZoom; + + /// The rotation when the map is first loaded. + final double initialRotation; + + /// Defines the visible bounds when the map is first loaded. Takes precedence + /// over [initialCenter]/[initialZoom]. + final CameraFit? initialCameraFit; + + final LatLngBounds? bounds; + final FitBoundsOptions boundsOptions; + + final bool? _debugMultiFingerGestureWinner; + final bool? _enableMultiFingerGestureRace; + final double? _rotationThreshold; + final int? _rotationWinGestures; + final double? _pinchZoomThreshold; + final int? _pinchZoomWinGestures; + final double? _pinchMoveThreshold; + final int? _pinchMoveWinGestures; + final bool? _enableScrollWheel; + final double? _scrollWheelVelocity; + + final double? minZoom; + final double? maxZoom; + + /// see [InteractiveFlag] for custom settings + final int? _interactiveFlags; + + final TapCallback? onTap; + final TapCallback? onSecondaryTap; + final LongPressCallback? onLongPress; + final PointerDownCallback? onPointerDown; + final PointerUpCallback? onPointerUp; + final PointerCancelCallback? onPointerCancel; + final PointerHoverCallback? onPointerHover; + final PositionCallback? onPositionChanged; + final MapEventCallback? onMapEvent; + + /// Define limits for viewing the map. + final CameraConstraint? _cameraConstraint; + + /// OnMapReady is called after the map runs it's initState. + /// At that point the map has assigned its state to the controller + /// 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. + final void Function()? onMapReady; + + final LatLngBounds? maxBounds; + + /// 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. + /// To ensure this doesn't happen, enable this flag to prevent the [FlutterMap] + /// widget from rebuilding. + final bool keepAlive; + + final InteractionOptions? _interactionOptions; + + const MapOptions({ + this.crs = const Epsg3857(), + @Deprecated( + 'Prefer `initialCenter` instead. ' + 'This option has been renamed to clarify its meaning. ' + 'This option is deprecated since v6.', + ) + LatLng? center, + LatLng initialCenter = const LatLng(50.5, 30.51), + @Deprecated( + 'Prefer `initialZoom` instead. ' + 'This option has been renamed to clarify its meaning. ' + 'This option is deprecated since v6.', + ) + double? zoom, + double initialZoom = 13.0, + @Deprecated( + 'Prefer `initialRotation` instead. ' + 'This option has been renamed to clarify its meaning. ' + 'This option is deprecated since v6.', + ) + double? rotation, + double initialRotation = 0.0, + @Deprecated( + 'Prefer `initialCameraFit` instead. ' + 'This option is now part of `initalCameraFit`. ' + 'This option is deprecated since v6.', + ) + this.bounds, + @Deprecated( + 'Prefer `initialCameraFit` instead. ' + 'This option is now part of `initalCameraFit`. ' + 'This option is deprecated since v6.', + ) + this.boundsOptions = const FitBoundsOptions(), + this.initialCameraFit, + CameraConstraint? cameraConstraint, + InteractionOptions? interactionOptions, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + int? interactiveFlags, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + bool? debugMultiFingerGestureWinner, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + bool? enableMultiFingerGestureRace, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + double? rotationThreshold, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + int? rotationWinGestures, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + double? pinchZoomThreshold, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + int? pinchZoomWinGestures, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + double? pinchMoveThreshold, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + int? pinchMoveWinGestures, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + bool? enableScrollWheel, + @Deprecated( + 'Prefer setting this in `interactionOptions`. ' + 'This option is now part of `interactionOptions` to group all interaction related options. ' + 'This option is deprecated since v6.', + ) + double? scrollWheelVelocity, + this.minZoom, + this.maxZoom, + this.onTap, + this.onSecondaryTap, + this.onLongPress, + this.onPointerDown, + this.onPointerUp, + this.onPointerCancel, + this.onPointerHover, + this.onPositionChanged, + this.onMapEvent, + this.onMapReady, + @Deprecated( + 'Prefer `cameraConstraint` instead. ' + 'This option is now replaced by `cameraConstraint` which provides more flexibile limiting of the map position. ' + 'This option is deprecated since v6.', + ) + this.maxBounds, + this.keepAlive = false, + }) : _interactionOptions = interactionOptions, + _interactiveFlags = interactiveFlags, + _debugMultiFingerGestureWinner = debugMultiFingerGestureWinner, + _enableMultiFingerGestureRace = enableMultiFingerGestureRace, + _rotationThreshold = rotationThreshold, + _rotationWinGestures = rotationWinGestures, + _pinchZoomThreshold = pinchZoomThreshold, + _pinchZoomWinGestures = pinchZoomWinGestures, + _pinchMoveThreshold = pinchMoveThreshold, + _pinchMoveWinGestures = pinchMoveWinGestures, + _enableScrollWheel = enableScrollWheel, + _scrollWheelVelocity = scrollWheelVelocity, + initialCenter = center ?? initialCenter, + initialZoom = zoom ?? initialZoom, + initialRotation = rotation ?? initialRotation, + _cameraConstraint = cameraConstraint; + + /// The options of the closest [FlutterMap] ancestor. If this is called from a + /// context with no [FlutterMap] ancestor, null is returned. + static MapOptions? maybeOf(BuildContext context) => + FlutterMapInheritedModel.maybeOptionsOf(context); + + /// The options of the closest [FlutterMap] ancestor. If this is called from a + /// context with no [FlutterMap] ancestor a [StateError] will be thrown. + static MapOptions of(BuildContext context) => + maybeOf(context) ?? + (throw StateError( + '`MapOptions.of()` should not be called outside a `FlutterMap` and its descendants')); + + InteractionOptions get interactionOptions => + _interactionOptions ?? + InteractionOptions( + flags: _interactiveFlags ?? InteractiveFlag.all, + debugMultiFingerGestureWinner: _debugMultiFingerGestureWinner ?? false, + enableMultiFingerGestureRace: _enableMultiFingerGestureRace ?? false, + rotationThreshold: _rotationThreshold ?? 20.0, + rotationWinGestures: _rotationWinGestures ?? MultiFingerGesture.rotate, + pinchZoomThreshold: _pinchZoomThreshold ?? 0.5, + pinchZoomWinGestures: _pinchZoomWinGestures ?? + MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, + pinchMoveThreshold: _pinchMoveThreshold ?? 40.0, + pinchMoveWinGestures: _pinchMoveWinGestures ?? + MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, + enableScrollWheel: _enableScrollWheel ?? true, + scrollWheelVelocity: _scrollWheelVelocity ?? 0.005, + ); + + // Note that this getter exists to make sure that the deprecated [maxBounds] + // option is consistently used. Making this a getter allows the constructor + // to remain const. + CameraConstraint get cameraConstraint => + _cameraConstraint ?? + (maxBounds != null + ? CameraConstraint.contain(bounds: maxBounds!) + : const CameraConstraint.unconstrained()); + + @override + bool operator ==(Object other) => + other is MapOptions && + crs == other.crs && + initialCenter == other.initialCenter && + initialZoom == other.initialZoom && + initialRotation == other.initialRotation && + initialCameraFit == other.initialCameraFit && + bounds == other.bounds && + boundsOptions == other.boundsOptions && + minZoom == other.minZoom && + maxZoom == other.maxZoom && + onTap == other.onTap && + onSecondaryTap == other.onSecondaryTap && + onLongPress == other.onLongPress && + onPointerDown == other.onPointerDown && + onPointerUp == other.onPointerUp && + onPointerCancel == other.onPointerCancel && + onPointerHover == other.onPointerHover && + onPositionChanged == other.onPositionChanged && + onMapEvent == other.onMapEvent && + cameraConstraint == other.cameraConstraint && + onMapReady == other.onMapReady && + maxBounds == other.maxBounds && + keepAlive == other.keepAlive && + interactionOptions == other.interactionOptions; + + @override + int get hashCode => Object.hashAll([ + crs, + initialCenter, + initialZoom, + initialRotation, + initialCameraFit, + bounds, + boundsOptions, + minZoom, + maxZoom, + onTap, + onSecondaryTap, + onLongPress, + onPointerDown, + onPointerUp, + onPointerCancel, + onPointerHover, + onPositionChanged, + onMapEvent, + cameraConstraint, + onMapReady, + keepAlive, + maxBounds, + interactionOptions, + ]); +} + +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] @@ -84,58 +401,8 @@ class MapOptions { final bool enableScrollWheel; final double scrollWheelVelocity; - final double? minZoom; - final double? maxZoom; - - /// see [InteractiveFlag] for custom settings - final int interactiveFlags; - - final TapCallback? onTap; - final TapCallback? onSecondaryTap; - final LongPressCallback? onLongPress; - final PointerDownCallback? onPointerDown; - final PointerUpCallback? onPointerUp; - final PointerCancelCallback? onPointerCancel; - final PointerHoverCallback? onPointerHover; - final PositionCallback? onPositionChanged; - final MapEventCallback? onMapEvent; - final bool slideOnBoundaries; - final Size? screenSize; - final bool adaptiveBoundaries; - final LatLng center; - final LatLngBounds? bounds; - final FitBoundsOptions boundsOptions; - final LatLng? swPanBoundary; - final LatLng? nePanBoundary; - - /// OnMapReady is called after the map runs it's initState. - /// At that point the map has assigned its state to the controller - /// 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 - final void Function()? onMapReady; - - /// Restrict outer edges of map to LatLng Bounds, to prevent gray areas when - /// panning or zooming. LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)) - /// would represent the full extent of the map, so no gray area outside of it. - final LatLngBounds? maxBounds; - - /// 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. - /// To ensure this doesn't happen, enable this flag to prevent the [FlutterMap] - /// widget from rebuilding. - final bool keepAlive; - - MapOptions({ - this.crs = const Epsg3857(), - LatLng? center, - this.bounds, - this.boundsOptions = const FitBoundsOptions(), - this.zoom = 13.0, - this.rotation = 0.0, + const InteractionOptions({ + this.flags = InteractiveFlag.all, this.debugMultiFingerGestureWinner = false, this.enableMultiFingerGestureRace = false, this.rotationThreshold = 20.0, @@ -148,52 +415,44 @@ class MapOptions { MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, this.enableScrollWheel = true, this.scrollWheelVelocity = 0.005, - this.minZoom, - this.maxZoom, - this.interactiveFlags = InteractiveFlag.all, - this.onTap, - this.onSecondaryTap, - this.onLongPress, - this.onPointerDown, - this.onPointerUp, - this.onPointerCancel, - this.onPointerHover, - this.onPositionChanged, - this.onMapEvent, - this.onMapReady, - this.slideOnBoundaries = false, - this.adaptiveBoundaries = false, - this.screenSize, - this.swPanBoundary, - this.nePanBoundary, - this.maxBounds, - this.keepAlive = false, - }) : center = center ?? const LatLng(50.5, 30.51), - assert(rotationThreshold >= 0.0), + }) : assert(rotationThreshold >= 0.0), assert(pinchZoomThreshold >= 0.0), - assert(pinchMoveThreshold >= 0.0) { - assert(!adaptiveBoundaries || screenSize != null, - 'screenSize must be set in order to enable adaptive boundaries.'); - } -} + assert(pinchMoveThreshold >= 0.0); -typedef MapEventCallback = void Function(MapEvent); + bool get dragEnabled => InteractiveFlag.hasDrag(flags); + bool get flingEnabled => InteractiveFlag.hasFlingAnimation(flags); + bool get doubleTapZoomEnabled => InteractiveFlag.hasDoubleTapZoom(flags); + bool get rotateEnabled => InteractiveFlag.hasRotate(flags); + bool get pinchZoomEnabled => InteractiveFlag.hasPinchZoom(flags); + bool get pinchMoveEnabled => InteractiveFlag.hasPinchMove(flags); -typedef TapCallback = void Function(TapPosition tapPosition, LatLng point); -typedef LongPressCallback = void Function( - TapPosition tapPosition, - LatLng point, -); -typedef PointerDownCallback = void Function( - PointerDownEvent event, - LatLng point, -); -typedef PointerUpCallback = void Function(PointerUpEvent event, LatLng point); -typedef PointerCancelCallback = void Function( - PointerCancelEvent event, - LatLng point, -); -typedef PointerHoverCallback = void Function( - PointerHoverEvent event, - LatLng point, -); + @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 && + enableScrollWheel == other.enableScrollWheel && + scrollWheelVelocity == other.scrollWheelVelocity; + + @override + int get hashCode => Object.hash( + flags, + debugMultiFingerGestureWinner, + enableMultiFingerGestureRace, + rotationThreshold, + rotationWinGestures, + pinchZoomThreshold, + pinchZoomWinGestures, + pinchMoveThreshold, + pinchMoveWinGestures, + enableScrollWheel, + scrollWheelVelocity, + ); +} diff --git a/lib/src/map/state.dart b/lib/src/map/state.dart deleted file mode 100644 index 57d5196db..000000000 --- a/lib/src/map/state.dart +++ /dev/null @@ -1,850 +0,0 @@ -import 'dart:math' as math; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/misc/private/bounds.dart'; -import 'package:flutter_map/src/gestures/gestures.dart'; -import 'package:latlong2/latlong.dart'; - -class FlutterMapState extends MapGestureMixin - with AutomaticKeepAliveClientMixin { - static const invalidSize = CustomPoint(-1, -1); - - final _positionedTapController = PositionedTapController(); - final _gestureArenaTeam = GestureArenaTeam(); - - bool _hasFitInitialBounds = false; - - @override - FlutterMapState get mapState => this; - - final _localController = MapController(); - @override - MapController get mapController => widget.mapController ?? _localController; - - @override - MapOptions get options => widget.options; - - @override - void initState() { - super.initState(); - - mapController.state = this; - _rotation = options.rotation; - _center = options.center; - _zoom = options.zoom; - _pixelBounds = getPixelBounds(); - _bounds = _calculateBounds(); - - WidgetsBinding.instance - .addPostFrameCallback((_) => options.onMapReady?.call()); - } - - @override - void didUpdateWidget(FlutterMap oldWidget) { - super.didUpdateWidget(oldWidget); - mapController.state = this; - } - - @override - Widget build(BuildContext context) { - super.build(context); - - final DeviceGestureSettings gestureSettings = - MediaQuery.gestureSettingsOf(context); - final Map gestures = - {}; - - gestures[TapGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(debugOwner: this), - (TapGestureRecognizer instance) { - instance - ..onTapDown = _positionedTapController.onTapDown - ..onTapUp = handleOnTapUp - ..onTap = _positionedTapController.onTap - ..onSecondaryTap = _positionedTapController.onSecondaryTap - ..onSecondaryTapDown = _positionedTapController.onTapDown; - }, - ); - - gestures[LongPressGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => LongPressGestureRecognizer(debugOwner: this), - (LongPressGestureRecognizer instance) { - instance.onLongPress = _positionedTapController.onLongPress; - }, - ); - - if (InteractiveFlag.hasFlag( - options.interactiveFlags, InteractiveFlag.drag)) { - gestures[VerticalDragGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => VerticalDragGestureRecognizer(debugOwner: this), - (VerticalDragGestureRecognizer instance) { - instance.onUpdate = (details) { - // Absorbing vertical drags - }; - instance.gestureSettings = gestureSettings; - instance.team ??= _gestureArenaTeam; - }, - ); - gestures[HorizontalDragGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => HorizontalDragGestureRecognizer(debugOwner: this), - (HorizontalDragGestureRecognizer instance) { - instance.onUpdate = (details) { - // Absorbing horizontal drags - }; - instance.gestureSettings = gestureSettings; - instance.team ??= _gestureArenaTeam; - }, - ); - } - - gestures[ScaleGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => ScaleGestureRecognizer(debugOwner: this), - (ScaleGestureRecognizer instance) { - instance - ..onStart = handleScaleStart - ..onUpdate = handleScaleUpdate - ..onEnd = handleScaleEnd; - instance.team ??= _gestureArenaTeam; - _gestureArenaTeam.captain = instance; - }, - ); - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - // Update on layout change. - setSize(constraints.maxWidth, constraints.maxHeight); - - // If bounds were provided set the initial center/zoom to match those - // bounds once the parent constraints are available. - if (options.bounds != null && - !_hasFitInitialBounds && - _parentConstraintsAreSet(context, constraints)) { - final target = - getBoundsCenterZoom(options.bounds!, options.boundsOptions); - _zoom = target.zoom; - _center = target.center; - _hasFitInitialBounds = true; - } - - _pixelBounds = getPixelBounds(); - _bounds = _calculateBounds(); - _pixelOrigin = getNewPixelOrigin(_center); - - return _MapStateInheritedWidget( - mapState: this, - child: Listener( - onPointerDown: onPointerDown, - onPointerUp: onPointerUp, - onPointerCancel: onPointerCancel, - onPointerHover: onPointerHover, - onPointerSignal: onPointerSignal, - child: PositionedTapDetector2( - controller: _positionedTapController, - onTap: handleTap, - onSecondaryTap: handleSecondaryTap, - onLongPress: handleLongPress, - onDoubleTap: handleDoubleTap, - doubleTapDelay: InteractiveFlag.hasFlag( - options.interactiveFlags, - InteractiveFlag.doubleTapZoom, - ) - ? null - : Duration.zero, - child: RawGestureDetector( - gestures: gestures, - child: ClipRect( - child: Stack( - children: [ - OverflowBox( - minWidth: size.x, - maxWidth: size.x, - minHeight: size.y, - maxHeight: size.y, - child: Transform.rotate( - angle: rotationRad, - child: Stack(children: widget.children), - ), - ), - Stack(children: widget.nonRotatedChildren), - ], - ), - ), - ), - ), - ), - ); - }, - ); - } - - // During flutter startup the native platform resolution is not immediately - // available which can cause constraints to be zero before they are updated - // in a subsequent build to the actual constraints. This check allows us to - // differentiate zero constraints caused by missing platform resolution vs - // zero constraints which were actually provided by the parent widget. - bool _parentConstraintsAreSet( - BuildContext context, BoxConstraints constraints) => - constraints.maxWidth != 0 || MediaQuery.sizeOf(context) != Size.zero; - - @override - bool get wantKeepAlive => options.keepAlive; - - late double _zoom; - late double _rotation; - - double get zoom => _zoom; - - double get rotation => _rotation; - - double get rotationRad => degToRadian(_rotation); - - late CustomPoint _pixelOrigin; - - CustomPoint get pixelOrigin => _pixelOrigin; - - late LatLng _center; - - LatLng get center => _center; - - late LatLngBounds _bounds; - - LatLngBounds get bounds => _bounds; - - late Bounds _pixelBounds; - - Bounds get pixelBounds => _pixelBounds; - - // Original size of the map where rotation isn't calculated - CustomPoint _nonrotatedSize = invalidSize; - - CustomPoint get nonrotatedSize => _nonrotatedSize; - - void setSize(double width, double height) { - if (_nonrotatedSize.x != width || _nonrotatedSize.y != height) { - final previousNonRotatedSize = _nonrotatedSize; - - _nonrotatedSize = CustomPoint(width, height); - _updateSizeByOriginalSizeAndRotation(); - - if (previousNonRotatedSize != invalidSize) { - emitMapEvent( - MapEventNonRotatedSizeChange( - source: MapEventSource.nonRotatedSizeChange, - previousNonRotatedSize: previousNonRotatedSize, - nonRotatedSize: _nonrotatedSize, - center: center, - zoom: zoom, - ), - ); - } - } - } - - // Extended size of the map where rotation is calculated - CustomPoint _size = invalidSize; - - CustomPoint get size => _size; - - void _updateSizeByOriginalSizeAndRotation() { - final originalWidth = _nonrotatedSize.x; - final originalHeight = _nonrotatedSize.y; - - if (_rotation != 0.0) { - final cosAngle = math.cos(rotationRad).abs(); - final sinAngle = math.sin(rotationRad).abs(); - final width = (originalWidth * cosAngle) + (originalHeight * sinAngle); - final height = (originalHeight * cosAngle) + (originalWidth * sinAngle); - - _size = CustomPoint(width, height); - } else { - _size = CustomPoint(originalWidth, originalHeight); - } - - _pixelOrigin = getNewPixelOrigin(_center); - } - - void emitMapEvent(MapEvent event) { - if (event.source == MapEventSource.mapController && event is MapEventMove) { - handleAnimationInterruptions(event); - } - - widget.options.onMapEvent?.call(event); - - mapController.mapEventSink.add(event); - } - - bool rotate( - double newRotation, { - bool hasGesture = false, - required MapEventSource source, - String? id, - }) { - if (newRotation != _rotation) { - final double oldRotation = _rotation; - //Apply state then emit events and callbacks - setState(() { - _rotation = newRotation; - }); - _updateSizeByOriginalSizeAndRotation(); - - emitMapEvent( - MapEventRotate( - id: id, - currentRotation: oldRotation, - targetRotation: _rotation, - center: _center, - zoom: _zoom, - source: source, - ), - ); - return true; - } - - return false; - } - - MoveAndRotateResult rotateAroundPoint( - double degree, { - CustomPoint? point, - Offset? offset, - bool hasGesture = false, - required MapEventSource source, - String? id, - }) { - if (point != null && offset != null) { - throw ArgumentError('Only one of `point` or `offset` may be non-null'); - } - if (point == null && offset == null) { - throw ArgumentError('One of `point` or `offset` must be non-null'); - } - - if (degree == rotation) return MoveAndRotateResult(false, false); - - if (offset == Offset.zero) { - return MoveAndRotateResult( - true, - rotate( - degree, - hasGesture: hasGesture, - source: source, - id: id, - ), - ); - } - - final rotationDiff = degree - rotation; - final rotationCenter = project(center, zoom) + - (point != null - ? (point - (nonrotatedSize / 2.0)) - : CustomPoint(offset!.dx, offset.dy)) - .rotate(rotationRad); - - return MoveAndRotateResult( - move( - unproject( - rotationCenter + - (project(center) - rotationCenter) - .rotate(degToRadian(rotationDiff)), - ), - zoom, - hasGesture: hasGesture, - source: source, - id: id, - ), - rotate( - rotation + rotationDiff, - hasGesture: hasGesture, - source: source, - id: id, - ), - ); - } - - MoveAndRotateResult moveAndRotate( - LatLng newCenter, - double newZoom, - double newRotation, { - Offset offset = Offset.zero, - required MapEventSource source, - String? id, - }) => - MoveAndRotateResult( - move(newCenter, newZoom, offset: offset, id: id, source: source), - rotate(newRotation, id: id, source: source), - ); - - bool move( - LatLng newCenter, - double newZoom, { - Offset offset = Offset.zero, - bool hasGesture = false, - required MapEventSource source, - String? id, - }) { - newZoom = fitZoomToBounds(newZoom); - - // Algorithm thanks to https://github.com/tlserver/flutter_map_location_marker - if (offset != Offset.zero) { - final newPoint = options.crs.latLngToPoint(newCenter, newZoom); - newCenter = options.crs.pointToLatLng( - rotatePoint( - newPoint, - newPoint - CustomPoint(offset.dx, offset.dy), - ), - newZoom, - ); - } - - if (isOutOfBounds(newCenter)) { - if (!options.slideOnBoundaries) return false; - newCenter = containPoint(newCenter, _center); - } - - if (options.maxBounds != null) { - final adjustedCenter = adjustCenterIfOutsideMaxBounds( - newCenter, - newZoom, - options.maxBounds!, - ); - - if (adjustedCenter == null) return false; - newCenter = adjustedCenter; - } - - if (newCenter == _center && newZoom == _zoom) return false; - - final oldCenter = _center; - final oldZoom = _zoom; - - setState(() { - _zoom = newZoom; - _center = newCenter; - }); - - _pixelBounds = getPixelBounds(); - _bounds = _calculateBounds(); - _pixelOrigin = getNewPixelOrigin(newCenter); - - final movementEvent = MapEventWithMove.fromSource( - targetCenter: newCenter, - targetZoom: newZoom, - oldCenter: oldCenter, - oldZoom: oldZoom, - hasGesture: hasGesture, - source: source, - id: id, - ); - if (movementEvent != null) emitMapEvent(movementEvent); - - options.onPositionChanged?.call( - MapPosition( - center: newCenter, - bounds: _bounds, - zoom: newZoom, - hasGesture: hasGesture, - ), - hasGesture, - ); - - return true; - } - - double fitZoomToBounds(double zoom) { - // Abide to min/max zoom - if (options.maxZoom != null) { - zoom = (zoom > options.maxZoom!) ? options.maxZoom! : zoom; - } - if (options.minZoom != null) { - zoom = (zoom < options.minZoom!) ? options.minZoom! : zoom; - } - return zoom; - } - - bool fitBounds( - LatLngBounds bounds, - FitBoundsOptions options, { - Offset offset = Offset.zero, - }) { - final target = getBoundsCenterZoom(bounds, options); - return move( - target.center, - target.zoom, - offset: offset, - source: MapEventSource.fitBounds, - ); - } - - CenterZoom centerZoomFitBounds( - LatLngBounds bounds, FitBoundsOptions options) { - return getBoundsCenterZoom(bounds, options); - } - - LatLngBounds _calculateBounds() { - return LatLngBounds( - unproject(_pixelBounds.bottomLeft), - unproject(_pixelBounds.topRight), - ); - } - - CenterZoom getBoundsCenterZoom( - LatLngBounds bounds, FitBoundsOptions options) { - final paddingTL = - CustomPoint(options.padding.left, options.padding.top); - final paddingBR = - CustomPoint(options.padding.right, options.padding.bottom); - - final paddingTotalXY = paddingTL + paddingBR; - - var zoom = getBoundsZoom( - bounds, - paddingTotalXY, - inside: options.inside, - forceIntegerZoomLevel: options.forceIntegerZoomLevel, - ); - zoom = math.min(options.maxZoom, zoom); - - final paddingOffset = (paddingBR - paddingTL) / 2; - final swPoint = project(bounds.southWest, zoom); - final nePoint = project(bounds.northEast, zoom); - - final CustomPoint projectedCenter; - if (_rotation != 0.0) { - final swPointRotated = swPoint.rotate(-rotationRad); - final nePointRotated = nePoint.rotate(-rotationRad); - final centerRotated = - (swPointRotated + nePointRotated) / 2 + paddingOffset; - - projectedCenter = centerRotated.rotate(rotationRad); - } else { - projectedCenter = (swPoint + nePoint) / 2 + paddingOffset; - } - - final center = unproject(projectedCenter, zoom); - return CenterZoom( - center: center, - zoom: zoom, - ); - } - - double getBoundsZoom(LatLngBounds bounds, CustomPoint padding, - {bool inside = false, bool forceIntegerZoomLevel = false}) { - var zoom = this.zoom; - final min = options.minZoom ?? 0.0; - final max = options.maxZoom ?? double.infinity; - final nw = bounds.northWest; - final se = bounds.southEast; - var size = nonrotatedSize - padding; - // Prevent negative size which results in NaN zoom value later on in the calculation - size = CustomPoint(math.max(0, size.x), math.max(0, size.y)); - - var boundsSize = Bounds(project(se, zoom), project(nw, zoom)).size; - if (_rotation != 0.0) { - final cosAngle = math.cos(rotationRad).abs(); - final sinAngle = math.sin(rotationRad).abs(); - boundsSize = CustomPoint( - (boundsSize.x * cosAngle) + (boundsSize.y * sinAngle), - (boundsSize.y * cosAngle) + (boundsSize.x * sinAngle), - ); - } - - final scaleX = size.x / boundsSize.x; - final scaleY = size.y / boundsSize.y; - final scale = inside ? math.max(scaleX, scaleY) : math.min(scaleX, scaleY); - - zoom = getScaleZoom(scale, zoom); - - if (forceIntegerZoomLevel) { - zoom = inside ? zoom.ceilToDouble() : zoom.floorToDouble(); - } - - return math.max(min, math.min(max, zoom)); - } - - CustomPoint project(LatLng latlng, [double? zoom]) { - zoom ??= _zoom; - return options.crs.latLngToPoint(latlng, zoom); - } - - LatLng unproject(CustomPoint point, [double? zoom]) { - zoom ??= _zoom; - return options.crs.pointToLatLng(point, zoom); - } - - LatLng layerPointToLatLng(CustomPoint point) { - return unproject(point); - } - - double getZoomScale(double toZoom, double fromZoom) { - final crs = options.crs; - return crs.scale(toZoom) / crs.scale(fromZoom); - } - - double getScaleZoom(double scale, double? fromZoom) { - final crs = options.crs; - fromZoom = fromZoom ?? _zoom; - return crs.zoom(scale * crs.scale(fromZoom)); - } - - Bounds? getPixelWorldBounds(double? zoom) { - return options.crs.getProjectedBounds(zoom ?? _zoom); - } - - Offset getOffsetFromOrigin(LatLng pos) { - final delta = project(pos) - _pixelOrigin; - return Offset(delta.x, delta.y); - } - - CustomPoint getNewPixelOrigin(LatLng center, [double? zoom]) { - final halfSize = size / 2.0; - return (project(center, zoom) - halfSize).round(); - } - - Bounds getPixelBounds([double? zoom]) { - CustomPoint halfSize = size / 2; - if (zoom != null) { - final scale = getZoomScale(this.zoom, zoom); - halfSize = size / (scale * 2); - } - final pixelCenter = project(center, zoom).floor().toDoublePoint(); - return Bounds(pixelCenter - halfSize, pixelCenter + halfSize); - } - - LatLng? adjustCenterIfOutsideMaxBounds( - LatLng testCenter, double testZoom, LatLngBounds maxBounds) { - LatLng? newCenter; - - final swPixel = project(maxBounds.southWest, testZoom); - final nePixel = project(maxBounds.northEast, testZoom); - - final centerPix = project(testCenter, testZoom); - - final halfSizeX = size.x / 2; - final halfSizeY = size.y / 2; - - // Try and find the edge value that the center could use to stay within - // the maxBounds. This should be ok for panning. If we zoom, it is possible - // there is no solution to keep all corners within the bounds. If the edges - // are still outside the bounds, don't return anything. - final leftOkCenter = math.min(swPixel.x, nePixel.x) + halfSizeX; - final rightOkCenter = math.max(swPixel.x, nePixel.x) - halfSizeX; - final topOkCenter = math.min(swPixel.y, nePixel.y) + halfSizeY; - final botOkCenter = math.max(swPixel.y, nePixel.y) - halfSizeY; - - double? newCenterX; - double? newCenterY; - - var wasAdjusted = false; - - if (centerPix.x < leftOkCenter) { - wasAdjusted = true; - newCenterX = leftOkCenter; - } else if (centerPix.x > rightOkCenter) { - wasAdjusted = true; - newCenterX = rightOkCenter; - } - - if (centerPix.y < topOkCenter) { - wasAdjusted = true; - newCenterY = topOkCenter; - } else if (centerPix.y > botOkCenter) { - wasAdjusted = true; - newCenterY = botOkCenter; - } - - if (!wasAdjusted) { - return testCenter; - } - - final newCx = newCenterX ?? centerPix.x; - final newCy = newCenterY ?? centerPix.y; - - // Have a final check, see if the adjusted center is within maxBounds. - // If not, give up. - if (newCx < leftOkCenter || - newCx > rightOkCenter || - newCy < topOkCenter || - newCy > botOkCenter) { - return null; - } else { - newCenter = unproject(CustomPoint(newCx, newCy), testZoom); - } - - return newCenter; - } - - // This will convert a latLng to a position that we could use with a widget - // outside of FlutterMap layer space. Eg using a Positioned Widget. - CustomPoint latLngToScreenPoint(LatLng latLng) { - final nonRotatedPixelOrigin = - (project(_center, zoom) - _nonrotatedSize / 2.0).round(); - - var point = options.crs.latLngToPoint(latLng, zoom); - - final mapCenter = options.crs.latLngToPoint(center, zoom); - - if (rotation != 0.0) { - point = rotatePoint(mapCenter, point, counterRotation: false); - } - - return point - nonRotatedPixelOrigin; - } - - LatLng pointToLatLng(CustomPoint localPoint) { - final localPointCenterDistance = CustomPoint( - (_nonrotatedSize.x / 2) - localPoint.x, - (_nonrotatedSize.y / 2) - localPoint.y, - ); - final mapCenter = options.crs.latLngToPoint(center, zoom); - - var point = mapCenter - localPointCenterDistance; - - if (rotation != 0.0) { - point = rotatePoint(mapCenter, point); - } - - return options.crs.pointToLatLng(point, zoom); - } - - // Sometimes we need to make allowances that a rotation already exists, so - // it needs to be reversed (pointToLatLng), and sometimes we want to use - // the same rotation to create a new position (latLngToScreenpoint). - // counterRotation just makes allowances this for this. - CustomPoint rotatePoint( - CustomPoint mapCenter, - CustomPoint point, { - bool counterRotation = true, - }) { - final counterRotationFactor = counterRotation ? -1 : 1; - - final m = Matrix4.identity() - ..translate(mapCenter.x, mapCenter.y) - ..rotateZ(rotationRad * counterRotationFactor) - ..translate(-mapCenter.x, -mapCenter.y); - - final tp = MatrixUtils.transformPoint(m, Offset(point.x, point.y)); - - return CustomPoint(tp.dx, tp.dy); - } - - _SafeArea? _safeAreaCache; - double? _safeAreaZoom; - - //if there is a pan boundary, do not cross - bool isOutOfBounds(LatLng center) { - if (options.adaptiveBoundaries) { - return !_safeArea!.contains(center); - } - if (options.swPanBoundary != null && options.nePanBoundary != null) { - if (center.latitude < options.swPanBoundary!.latitude || - center.latitude > options.nePanBoundary!.latitude) { - return true; - } else if (center.longitude < options.swPanBoundary!.longitude || - center.longitude > options.nePanBoundary!.longitude) { - return true; - } - } - return false; - } - - LatLng containPoint(LatLng point, LatLng fallback) { - if (options.adaptiveBoundaries) { - return _safeArea!.containPoint(point, fallback); - } else { - return LatLng( - point.latitude.clamp( - options.swPanBoundary!.latitude, options.nePanBoundary!.latitude), - point.longitude.clamp( - options.swPanBoundary!.longitude, options.nePanBoundary!.longitude), - ); - } - } - - _SafeArea? get _safeArea { - final controllerZoom = _zoom; - if (controllerZoom != _safeAreaZoom || _safeAreaCache == null) { - _safeAreaZoom = controllerZoom; - final halfScreenHeight = _calculateScreenHeightInDegrees() / 2; - final halfScreenWidth = _calculateScreenWidthInDegrees() / 2; - final southWestLatitude = - options.swPanBoundary!.latitude + halfScreenHeight; - final southWestLongitude = - options.swPanBoundary!.longitude + halfScreenWidth; - final northEastLatitude = - options.nePanBoundary!.latitude - halfScreenHeight; - final northEastLongitude = - options.nePanBoundary!.longitude - halfScreenWidth; - _safeAreaCache = _SafeArea( - LatLng( - southWestLatitude, - southWestLongitude, - ), - LatLng( - northEastLatitude, - northEastLongitude, - ), - ); - } - return _safeAreaCache; - } - - double _calculateScreenWidthInDegrees() { - final degreesPerPixel = 360 / math.pow(2, zoom + 8); - return options.screenSize!.width * degreesPerPixel; - } - - double _calculateScreenHeightInDegrees() => - options.screenSize!.height * 170.102258 / math.pow(2, zoom + 8); - - static FlutterMapState? maybeOf(BuildContext context) => context - .dependOnInheritedWidgetOfExactType<_MapStateInheritedWidget>() - ?.mapState; - - static FlutterMapState of(BuildContext context) => - maybeOf(context) ?? - (throw StateError( - '`FlutterMapState.of()` should not be called outside a `FlutterMap` and its children')); -} - -class _SafeArea { - final LatLngBounds bounds; - final bool isLatitudeBlocked; - final bool isLongitudeBlocked; - - _SafeArea(LatLng southWest, LatLng northEast) - : bounds = LatLngBounds(southWest, northEast), - isLatitudeBlocked = southWest.latitude > northEast.latitude, - isLongitudeBlocked = southWest.longitude > northEast.longitude; - - bool contains(LatLng point) => - isLatitudeBlocked || isLongitudeBlocked ? false : bounds.contains(point); - - LatLng containPoint(LatLng point, LatLng fallback) => LatLng( - isLatitudeBlocked - ? fallback.latitude - : point.latitude.clamp(bounds.south, bounds.north), - isLongitudeBlocked - ? fallback.longitude - : point.longitude.clamp(bounds.west, bounds.east), - ); -} - -class _MapStateInheritedWidget extends InheritedWidget { - const _MapStateInheritedWidget({ - required this.mapState, - required super.child, - }); - - final FlutterMapState mapState; - - /// This return value does not appear to affect anything, no matter it's value - @override - bool updateShouldNotify(_MapStateInheritedWidget oldWidget) => true; -} diff --git a/lib/src/map/widget.dart b/lib/src/map/widget.dart index 9ae646296..c9f27dd45 100644 --- a/lib/src/map/widget.dart +++ b/lib/src/map/widget.dart @@ -1,7 +1,13 @@ import 'package:flutter/widgets.dart'; - -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/map/state.dart'; +import 'package:flutter_map/src/gestures/flutter_map_interactive_viewer.dart'; +import 'package:flutter_map/src/gestures/map_events.dart'; +import 'package:flutter_map/src/map/camera/camera_fit.dart'; +import 'package:flutter_map/src/map/inherited_model.dart'; +import 'package:flutter_map/src/map/internal_controller.dart'; +import 'package:flutter_map/src/map/map_controller.dart'; +import 'package:flutter_map/src/map/map_controller_impl.dart'; +import 'package:flutter_map/src/map/options.dart'; +import 'package:flutter_map/src/misc/point.dart'; /// Renders an interactive geographical map as a widget /// @@ -33,5 +39,157 @@ class FlutterMap extends StatefulWidget { final MapController? mapController; @override - State createState() => FlutterMapState(); + State createState() => FlutterMapStateContainer(); +} + +class FlutterMapStateContainer extends State { + bool _initialCameraFitApplied = false; + + late final FlutterMapInternalController _flutterMapInternalController; + late MapControllerImpl _mapController; + late bool _mapControllerCreatedInternally; + + @override + void initState() { + super.initState(); + _flutterMapInternalController = + FlutterMapInternalController(widget.options); + _initializeAndLinkMapController(); + + WidgetsBinding.instance + .addPostFrameCallback((_) => widget.options.onMapReady?.call()); + } + + @override + void didUpdateWidget(FlutterMap oldWidget) { + if (oldWidget.options != widget.options) { + _flutterMapInternalController.setOptions(widget.options); + } + if (oldWidget.mapController != widget.mapController) { + _initializeAndLinkMapController(); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + if (_mapControllerCreatedInternally) _mapController.dispose(); + _flutterMapInternalController.dispose(); + super.dispose(); + } + + void _initializeAndLinkMapController() { + _mapController = + (widget.mapController ?? MapController()) as MapControllerImpl; + _mapControllerCreatedInternally = widget.mapController == null; + _flutterMapInternalController.linkMapController(_mapController); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + _updateAndEmitSizeIfConstraintsChanged(constraints); + _applyInitialCameraFit(constraints); + + return FlutterMapInteractiveViewer( + controller: _flutterMapInternalController, + builder: (context, options, camera) => FlutterMapInheritedModel( + controller: _mapController, + options: options, + camera: camera, + child: ClipRect( + child: Stack( + children: [ + OverflowBox( + minWidth: camera.size.x, + maxWidth: camera.size.x, + minHeight: camera.size.y, + maxHeight: camera.size.y, + child: Transform.rotate( + angle: camera.rotationRad, + child: Stack(children: widget.children), + ), + ), + ...widget.nonRotatedChildren, + ], + ), + ), + ), + ); + }, + ); + } + + void _applyInitialCameraFit(BoxConstraints constraints) { + // If an initial camera fit was provided apply it to the map state once the + // the parent constraints are available. + + if (!_initialCameraFitApplied && + (widget.options.bounds != null || + widget.options.initialCameraFit != null) && + _parentConstraintsAreSet(context, constraints)) { + _initialCameraFitApplied = true; + + final CameraFit cameraFit; + + if (widget.options.bounds != null) { + // Create the camera fit from the deprecated option. + final fitBoundsOptions = widget.options.boundsOptions; + cameraFit = fitBoundsOptions.inside + ? CameraFit.insideBounds( + bounds: widget.options.bounds!, + padding: fitBoundsOptions.padding, + maxZoom: fitBoundsOptions.maxZoom, + forceIntegerZoomLevel: fitBoundsOptions.forceIntegerZoomLevel, + ) + : CameraFit.bounds( + bounds: widget.options.bounds!, + padding: fitBoundsOptions.padding, + maxZoom: fitBoundsOptions.maxZoom, + forceIntegerZoomLevel: fitBoundsOptions.forceIntegerZoomLevel, + ); + } else { + cameraFit = widget.options.initialCameraFit!; + } + + _flutterMapInternalController.fitCamera( + cameraFit, + offset: Offset.zero, + ); + } + } + + void _updateAndEmitSizeIfConstraintsChanged(BoxConstraints constraints) { + final nonRotatedSize = CustomPoint( + constraints.maxWidth, + constraints.maxHeight, + ); + final oldCamera = _flutterMapInternalController.camera; + if (_flutterMapInternalController + .setNonRotatedSizeWithoutEmittingEvent(nonRotatedSize)) { + final newMapCamera = _flutterMapInternalController.camera; + + // Avoid emitting the event during build otherwise if the user calls + // setState in the onMapEvent callback it will throw. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _flutterMapInternalController.nonRotatedSizeChange( + MapEventSource.nonRotatedSizeChange, + oldCamera, + newMapCamera, + ); + } + }); + } + } + + // During Flutter startup the native platform resolution is not immediately + // available which can cause constraints to be zero before they are updated + // in a subsequent build to the actual constraints. This check allows us to + // differentiate zero constraints caused by missing platform resolution vs + // zero constraints which were actually provided by the parent widget. + bool _parentConstraintsAreSet( + BuildContext context, BoxConstraints constraints) => + constraints.maxWidth != 0 || MediaQuery.sizeOf(context) != Size.zero; } diff --git a/lib/src/misc/center_zoom.dart b/lib/src/misc/center_zoom.dart index 8bf2c8aa6..d7a7063c7 100644 --- a/lib/src/misc/center_zoom.dart +++ b/lib/src/misc/center_zoom.dart @@ -7,5 +7,21 @@ class CenterZoom { /// Zoom value final double zoom; + CenterZoom({required this.center, required this.zoom}); + + CenterZoom withCenter(LatLng center) => + CenterZoom(center: center, zoom: zoom); + + 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/fit_bounds_options.dart b/lib/src/misc/fit_bounds_options.dart index d0b87b0d4..3ce8348a4 100644 --- a/lib/src/misc/fit_bounds_options.dart +++ b/lib/src/misc/fit_bounds_options.dart @@ -10,6 +10,11 @@ class FitBoundsOptions { /// to the next suitable integer. final bool forceIntegerZoomLevel; + @Deprecated( + 'Prefer `CameraFit.bounds` instead. ' + 'This class has been renamed to clarify its meaning and is now a sublass of CameraFit to allow other fit types. ' + 'This class is deprecated since v6.', + ) const FitBoundsOptions({ this.padding = EdgeInsets.zero, this.maxZoom = 17.0, diff --git a/lib/src/misc/point.dart b/lib/src/misc/point.dart index c66b436be..2077bb6ee 100644 --- a/lib/src/misc/point.dart +++ b/lib/src/misc/point.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:ui'; /// Data represenation of point located on map instance /// where [x] is horizontal and [y] is vertical pixel value @@ -81,3 +82,7 @@ class CustomPoint extends math.Point { @override String toString() => 'CustomPoint ($x, $y)'; } + +extension OffsetToCustomPointExtension on Offset { + CustomPoint toCustomPoint() => CustomPoint(dx, dy); +} diff --git a/lib/src/misc/position.dart b/lib/src/misc/position.dart index 6f9bff191..adb88404a 100644 --- a/lib/src/misc/position.dart +++ b/lib/src/misc/position.dart @@ -1,4 +1,4 @@ -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; import 'package:latlong2/latlong.dart'; class MapPosition { diff --git a/lib/src/misc/private/bounds.dart b/lib/src/misc/private/bounds.dart index 191fc86a9..aea499226 100644 --- a/lib/src/misc/private/bounds.dart +++ b/lib/src/misc/private/bounds.dart @@ -8,13 +8,42 @@ class Bounds { final CustomPoint min; final CustomPoint max; + const Bounds._(this.min, this.max); + factory Bounds(CustomPoint a, CustomPoint b) { final bounds1 = Bounds._(a, b); final bounds2 = bounds1.extend(a); return bounds2.extend(b); } - const Bounds._(this.min, this.max); + static Bounds containing(Iterable> points) { + var maxX = double.negativeInfinity; + var maxY = double.negativeInfinity; + var minX = double.infinity; + var minY = double.infinity; + + for (final point in points) { + if (point.x > maxX) { + maxX = point.x; + } + if (point.x < minX) { + minX = point.x; + } + if (point.y > maxY) { + maxY = point.y; + } + if (point.y < minY) { + minY = point.y; + } + } + + final bounds = Bounds._( + CustomPoint(minX, minY), + CustomPoint(maxX, maxY), + ); + + return bounds; + } /// Creates a new [Bounds] obtained by expanding the current ones with a new /// point. diff --git a/test/core/bounds_test.dart b/test/core/bounds_test.dart index fe58eab52..c05643d0b 100644 --- a/test/core/bounds_test.dart +++ b/test/core/bounds_test.dart @@ -1,5 +1,5 @@ -import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:flutter_map/src/misc/point.dart'; +import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:flutter_test/flutter_test.dart'; import '../helpers/core.dart'; diff --git a/test/flutter_map_controller_test.dart b/test/flutter_map_controller_test.dart index 037668f84..cfaeccb39 100644 --- a/test/flutter_map_controller_test.dart +++ b/test/flutter_map_controller_test.dart @@ -1,14 +1,13 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.dart'; +import 'package:flutter_map/src/map/camera/camera_fit.dart'; +import 'package:flutter_map/src/map/map_controller.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; -import 'test_utils/mocks.dart'; import 'test_utils/test_app.dart'; void main() { - setupMocks(); - testWidgets('test fit bounds methods', (tester) async { final controller = MapController(); final bounds = LatLngBounds( @@ -20,30 +19,24 @@ void main() { await tester.pumpWidget(TestApp(controller: controller)); { - const fitOptions = FitBoundsOptions(); - + final cameraConstraint = CameraFit.bounds(bounds: bounds); final expectedBounds = LatLngBounds( const LatLng(51.00145915187144, -0.3079873797085076), const LatLng(52.001427481787005, 1.298485398623206), ); const expectedZoom = 7.451812751543818; - final fit = controller.centerZoomFitBounds(bounds, options: fitOptions); - controller.move(fit.center, fit.zoom); - await tester.pump(); - expect(controller.bounds, equals(expectedBounds)); - expect(controller.center, equals(expectedCenter)); - expect(controller.zoom, equals(expectedZoom)); - - controller.fitBounds(bounds, options: fitOptions); + controller.fitCamera(cameraConstraint); await tester.pump(); - expect(controller.bounds, equals(expectedBounds)); - expect(controller.center, equals(expectedCenter)); - expect(controller.zoom, equals(expectedZoom)); + final camera = controller.camera; + expect(camera.visibleBounds, equals(expectedBounds)); + expect(camera.center, equals(expectedCenter)); + expect(camera.zoom, equals(expectedZoom)); } { - const fitOptions = FitBoundsOptions( + final cameraConstraint = CameraFit.bounds( + bounds: bounds, forceIntegerZoomLevel: true, ); @@ -53,23 +46,17 @@ void main() { ); const expectedZoom = 7; - final fit = controller.centerZoomFitBounds(bounds, options: fitOptions); - controller.move(fit.center, fit.zoom); + controller.fitCamera(cameraConstraint); await tester.pump(); - expect(controller.bounds, equals(expectedBounds)); - expect(controller.center, equals(expectedCenter)); - expect(controller.zoom, equals(expectedZoom)); - - controller.fitBounds(bounds, options: fitOptions); - await tester.pump(); - expect(controller.bounds, equals(expectedBounds)); - expect(controller.center, equals(expectedCenter)); - expect(controller.zoom, equals(expectedZoom)); + final camera = controller.camera; + expect(camera.visibleBounds, equals(expectedBounds)); + expect(camera.center, equals(expectedCenter)); + expect(camera.zoom, equals(expectedZoom)); } { - const fitOptions = FitBoundsOptions( - inside: true, + final cameraConstraint = CameraFit.insideBounds( + bounds: bounds, ); final expectedBounds = LatLngBounds( @@ -78,24 +65,18 @@ void main() { ); const expectedZoom = 8.135709286104404; - final fit = controller.centerZoomFitBounds(bounds, options: fitOptions); - controller.move(fit.center, fit.zoom); + controller.fitCamera(cameraConstraint); await tester.pump(); - expect(controller.bounds, equals(expectedBounds)); - expect(controller.center, equals(expectedCenter)); - expect(controller.zoom, equals(expectedZoom)); - - controller.fitBounds(bounds, options: fitOptions); - await tester.pump(); - expect(controller.bounds, equals(expectedBounds)); - expect(controller.center, equals(expectedCenter)); - expect(controller.zoom, equals(expectedZoom)); + final camera = controller.camera; + expect(camera.visibleBounds, equals(expectedBounds)); + expect(camera.center, equals(expectedCenter)); + expect(camera.zoom, equals(expectedZoom)); } { - const fitOptions = FitBoundsOptions( - inside: true, + final cameraConstraint = CameraFit.insideBounds( + bounds: bounds, forceIntegerZoomLevel: true, ); @@ -105,20 +86,15 @@ void main() { ); const expectedZoom = 9; - final fit = controller.centerZoomFitBounds(bounds, options: fitOptions); - controller.move(fit.center, fit.zoom); - await tester.pump(); - expect(controller.bounds, equals(expectedBounds)); - expect(controller.center, equals(expectedCenter)); - expect(controller.zoom, equals(expectedZoom)); - - controller.fitBounds(bounds, options: fitOptions); + controller.fitCamera(cameraConstraint); await tester.pump(); - expect(controller.bounds, equals(expectedBounds)); - expect(controller.center, equals(expectedCenter)); - expect(controller.zoom, equals(expectedZoom)); + final camera = controller.camera; + expect(camera.visibleBounds, equals(expectedBounds)); + expect(camera.center, equals(expectedCenter)); + expect(camera.zoom, equals(expectedZoom)); } }); + testWidgets('test fit bounds methods with rotation', (tester) async { final controller = MapController(); final bounds = LatLngBounds( @@ -130,75 +106,50 @@ void main() { Future testFitBounds({ required double rotation, - required FitBoundsOptions options, + required CameraFit cameraConstraint, required LatLngBounds expectedBounds, required LatLng expectedCenter, required double expectedZoom, }) async { controller.rotate(rotation); - final fit = controller.centerZoomFitBounds(bounds, options: options); - controller.move(fit.center, fit.zoom); - await tester.pump(); - expect( - controller.bounds?.northWest.latitude, - moreOrLessEquals(expectedBounds.northWest.latitude), - ); - expect( - controller.bounds?.northWest.longitude, - moreOrLessEquals(expectedBounds.northWest.longitude), - ); - expect( - controller.bounds?.southEast.latitude, - moreOrLessEquals(expectedBounds.southEast.latitude), - ); - expect( - controller.bounds?.southEast.longitude, - moreOrLessEquals(expectedBounds.southEast.longitude), - ); - expect( - controller.center.latitude, - moreOrLessEquals(expectedCenter.latitude), - ); - expect( - controller.center.longitude, - moreOrLessEquals(expectedCenter.longitude), - ); - expect(controller.zoom, moreOrLessEquals(expectedZoom)); - controller.fitBounds(bounds, options: options); + controller.fitCamera(cameraConstraint); await tester.pump(); expect( - controller.bounds?.northWest.latitude, + controller.camera.visibleBounds.northWest.latitude, moreOrLessEquals(expectedBounds.northWest.latitude), ); expect( - controller.bounds?.northWest.longitude, + controller.camera.visibleBounds.northWest.longitude, moreOrLessEquals(expectedBounds.northWest.longitude), ); expect( - controller.bounds?.southEast.latitude, + controller.camera.visibleBounds.southEast.latitude, moreOrLessEquals(expectedBounds.southEast.latitude), ); expect( - controller.bounds?.southEast.longitude, + controller.camera.visibleBounds.southEast.longitude, moreOrLessEquals(expectedBounds.southEast.longitude), ); expect( - controller.center.latitude, + controller.camera.center.latitude, moreOrLessEquals(expectedCenter.latitude), ); expect( - controller.center.longitude, + controller.camera.center.longitude, moreOrLessEquals(expectedCenter.longitude), ); - expect(controller.zoom, moreOrLessEquals(expectedZoom)); + expect(controller.camera.zoom, moreOrLessEquals(expectedZoom)); } // Tests with no padding await testFitBounds( rotation: -360, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(4.220875035073316, 28.95466920920177), const LatLng(-1.3562295282017047, 34.53572340816548), @@ -208,7 +159,10 @@ void main() { ); await testFitBounds( rotation: -300, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(6.229878688707217, 26.943661553415026), const LatLng(-3.3298966942067114, 36.517625059412495), @@ -218,7 +172,10 @@ void main() { ); await testFitBounds( rotation: -240, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(6.229878688707217, 26.943661553415026), const LatLng(-3.3298966942067114, 36.517625059412495), @@ -228,7 +185,10 @@ void main() { ); await testFitBounds( rotation: -180, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(4.220875035073316, 28.95466920920177), const LatLng(-1.3562295282017047, 34.53572340816548), @@ -238,7 +198,10 @@ void main() { ); await testFitBounds( rotation: -120, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(6.2298786887073065, 26.943661553414902), const LatLng(-3.329896694206635, 36.517625059412374), @@ -248,7 +211,10 @@ void main() { ); await testFitBounds( rotation: -60, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(6.2298786887073065, 26.943661553414902), const LatLng(-3.329896694206635, 36.517625059412374), @@ -258,7 +224,10 @@ void main() { ); await testFitBounds( rotation: 0, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(4.220875035073316, 28.95466920920177), const LatLng(-1.3562295282017047, 34.53572340816548), @@ -268,7 +237,10 @@ void main() { ); await testFitBounds( rotation: 60, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(6.229878688707217, 26.943661553415026), const LatLng(-3.3298966942067114, 36.517625059412495), @@ -278,7 +250,10 @@ void main() { ); await testFitBounds( rotation: 120, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(6.229878688707217, 26.943661553415026), const LatLng(-3.3298966942067114, 36.517625059412495), @@ -288,7 +263,10 @@ void main() { ); await testFitBounds( rotation: 180, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(4.220875035073316, 28.95466920920177), const LatLng(-1.3562295282017047, 34.53572340816548), @@ -298,7 +276,10 @@ void main() { ); await testFitBounds( rotation: 240, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(6.229878688706365, 26.94366155341602), const LatLng(-3.3298966942076276, 36.51762505941353), @@ -308,7 +289,10 @@ void main() { ); await testFitBounds( rotation: 300, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(6.229878688707217, 26.943661553415026), const LatLng(-3.3298966942067114, 36.517625059412495), @@ -318,7 +302,10 @@ void main() { ); await testFitBounds( rotation: 360, - options: const FitBoundsOptions(padding: EdgeInsets.zero), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), expectedBounds: LatLngBounds( const LatLng(4.220875035073316, 28.95466920920177), const LatLng(-1.3562295282017047, 34.53572340816548), @@ -333,7 +320,10 @@ void main() { await testFitBounds( rotation: -360, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(4.604066851713044, 28.560190151047802), const LatLng(-1.732813138431261, 34.902297195324785), @@ -343,7 +333,10 @@ void main() { ); await testFitBounds( rotation: -300, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.862564855409817, 26.292484184306595), const LatLng(-3.997225315187129, 37.171988168394705), @@ -353,7 +346,10 @@ void main() { ); await testFitBounds( rotation: -240, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.862564855410326, 26.292484184305955), const LatLng(-3.9972253151865824, 37.17198816839402), @@ -363,7 +359,10 @@ void main() { ); await testFitBounds( rotation: -180, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(4.6040668517126235, 28.560190151048324), const LatLng(-1.7328131384316936, 34.9022971953253), @@ -373,7 +372,10 @@ void main() { ); await testFitBounds( rotation: -120, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.862564855410096, 26.292484184306193), const LatLng(-3.997225315186811, 37.17198816839431), @@ -383,7 +385,10 @@ void main() { ); await testFitBounds( rotation: -60, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.8625648554105165, 26.292484184305717), const LatLng(-3.9972253151863786, 37.17198816839379), @@ -393,7 +398,10 @@ void main() { ); await testFitBounds( rotation: 0, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(4.604066851712751, 28.560190151048204), const LatLng(-1.732813138431579, 34.90229719532515), @@ -403,7 +411,10 @@ void main() { ); await testFitBounds( rotation: 60, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.862564855410008, 26.292484184306353), const LatLng(-3.9972253151869386, 37.17198816839443), @@ -413,7 +424,10 @@ void main() { ); await testFitBounds( rotation: 120, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.8625648554105165, 26.292484184305717), const LatLng(-3.9972253151863786, 37.17198816839379), @@ -423,7 +437,10 @@ void main() { ); await testFitBounds( rotation: 180, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(4.6040668517126235, 28.560190151048324), const LatLng(-1.7328131384316936, 34.9022971953253), @@ -433,7 +450,10 @@ void main() { ); await testFitBounds( rotation: 240, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.862564855410008, 26.292484184306353), const LatLng(-3.9972253151869386, 37.17198816839443), @@ -443,7 +463,10 @@ void main() { ); await testFitBounds( rotation: 300, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.862564855411076, 26.292484184305035), const LatLng(-3.997225315185781, 37.171988168393064), @@ -453,7 +476,10 @@ void main() { ); await testFitBounds( rotation: 360, - options: const FitBoundsOptions(padding: symmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: symmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(4.604066851711988, 28.56019015104908), const LatLng(-1.7328131384323806, 34.902297195326106), @@ -468,7 +494,10 @@ void main() { await testFitBounds( rotation: -360, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(4.634132562246874, 28.54085445883965), const LatLng(-2.1664538621122844, 35.34701811611249), @@ -478,7 +507,10 @@ void main() { ); await testFitBounds( rotation: -300, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(7.353914452121884, 26.258676859164435), const LatLng(-4.297341450189851, 37.9342421103809), @@ -488,7 +520,10 @@ void main() { ); await testFitBounds( rotation: -240, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(7.6081448623143, 26.00226365003461), const LatLng(-4.041607090303907, 37.677828901251075), @@ -498,7 +533,10 @@ void main() { ); await testFitBounds( rotation: -180, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(5.041046797566381, 28.132484639403017), const LatLng(-1.7583244079256093, 34.93864829667586), @@ -508,7 +546,10 @@ void main() { ); await testFitBounds( rotation: -120, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(7.184346279929569, 25.53217276663045), const LatLng(-4.467783700569064, 37.207738017846864), @@ -518,7 +559,10 @@ void main() { ); await testFitBounds( rotation: -60, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.929875826124592, 25.788585975760196), const LatLng(-4.723372343263628, 37.46415122697666), @@ -528,7 +572,10 @@ void main() { ); await testFitBounds( rotation: 0, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(4.63413256224709, 28.540854458839405), const LatLng(-2.166453862112043, 35.347018116112245), @@ -538,7 +585,10 @@ void main() { ); await testFitBounds( rotation: 60, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(7.353914452122737, 26.258676859163398), const LatLng(-4.297341450188935, 37.93424211037982), @@ -548,7 +598,10 @@ void main() { ); await testFitBounds( rotation: 120, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(7.6081448623143, 26.00226365003461), const LatLng(-4.041607090303907, 37.677828901251075), @@ -558,7 +611,10 @@ void main() { ); await testFitBounds( rotation: 180, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(5.041046797566381, 28.132484639403017), const LatLng(-1.7583244079256093, 34.93864829667586), @@ -568,7 +624,10 @@ void main() { ); await testFitBounds( rotation: 240, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(7.184346279929569, 25.53217276663045), const LatLng(-4.467783700569064, 37.207738017846864), @@ -578,7 +637,10 @@ void main() { ); await testFitBounds( rotation: 300, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(6.929875826125113, 25.788585975759595), const LatLng(-4.7233723432630805, 37.46415122697602), @@ -588,7 +650,10 @@ void main() { ); await testFitBounds( rotation: 360, - options: const FitBoundsOptions(padding: asymmetricPadding), + cameraConstraint: CameraFit.bounds( + bounds: bounds, + padding: asymmetricPadding, + ), expectedBounds: LatLngBounds( const LatLng(4.634132562246874, 28.54085445883965), const LatLng(-2.1664538621122844, 35.34701811611249), @@ -597,4 +662,770 @@ void main() { expectedZoom: 5.368867444131886, ); }); + + testWidgets('test fit coordinates methods', (tester) async { + final controller = MapController(); + const coordinates = [ + LatLng(4.214943, 33.925781), + LatLng(3.480523, 30.844116), + LatLng(-1.362176, 29.575195), + LatLng(-0.999705, 33.925781), + ]; + + await tester.pumpWidget(TestApp(controller: controller)); + + Future testFitCoordinates({ + required double rotation, + required FitCoordinates fitCoordinates, + required LatLng expectedCenter, + required double expectedZoom, + }) async { + controller.rotate(rotation); + + controller.fitCamera(fitCoordinates); + await tester.pump(); + expect( + controller.camera.center.latitude, + moreOrLessEquals(expectedCenter.latitude), + ); + expect( + controller.camera.center.longitude, + moreOrLessEquals(expectedCenter.longitude), + ); + expect(controller.camera.zoom, moreOrLessEquals(expectedZoom)); + } + + FitCoordinates fitCoordinates({ + EdgeInsets padding = EdgeInsets.zero, + }) => + CameraFit.coordinates( + coordinates: coordinates, + padding: padding, + ) as FitCoordinates; + + // Tests with no padding + + await testFitCoordinates( + rotation: 45, + fitCoordinates: fitCoordinates(), + expectedCenter: const LatLng(1.0175550985081283, 32.16110216543986), + expectedZoom: 5.323677289246632, + ); + await testFitCoordinates( + rotation: 90, + fitCoordinates: fitCoordinates(), + expectedCenter: const LatLng(1.4280748738291353, 31.75048799999998), + expectedZoom: 5.655171629288528, + ); + await testFitCoordinates( + rotation: 135, + fitCoordinates: fitCoordinates(), + expectedCenter: const LatLng(1.0175550985081538, 32.16110216543989), + expectedZoom: 5.323677289246641, + ); + await testFitCoordinates( + rotation: 180, + fitCoordinates: fitCoordinates(), + expectedCenter: const LatLng(1.4280748738291353, 31.75048799999998), + expectedZoom: 5.655171629288529, + ); + await testFitCoordinates( + rotation: 225, + fitCoordinates: fitCoordinates(), + expectedCenter: const LatLng(1.0175550985080901, 32.16110216543997), + expectedZoom: 5.323677289246641, + ); + await testFitCoordinates( + rotation: 270, + fitCoordinates: fitCoordinates(), + expectedCenter: const LatLng(1.4280748738291353, 31.75048799999998), + expectedZoom: 5.655171629288529, + ); + await testFitCoordinates( + rotation: 315, + fitCoordinates: fitCoordinates(), + expectedCenter: const LatLng(1.0175550985081538, 32.16110216543989), + expectedZoom: 5.323677289246641, + ); + await testFitCoordinates( + rotation: 360, + fitCoordinates: fitCoordinates(), + expectedCenter: const LatLng(1.4280748738291353, 31.75048799999998), + expectedZoom: 5.655171629288529, + ); + + // Tests with symmetric padding + + const equalPadding = EdgeInsets.all(12); + + await testFitCoordinates( + rotation: 45, + fitCoordinates: fitCoordinates(padding: equalPadding), + expectedCenter: const LatLng(1.0175550985081538, 32.16110216543986), + expectedZoom: 5.139252718109209, + ); + await testFitCoordinates( + rotation: 90, + fitCoordinates: fitCoordinates(padding: equalPadding), + expectedCenter: const LatLng(1.4280748738291353, 31.75048799999998), + expectedZoom: 5.470747058151099, + ); + await testFitCoordinates( + rotation: 135, + fitCoordinates: fitCoordinates(padding: equalPadding), + expectedCenter: const LatLng(1.0175550985081538, 32.161102165439935), + expectedZoom: 5.139252718109208, + ); + await testFitCoordinates( + rotation: 180, + fitCoordinates: fitCoordinates(padding: equalPadding), + expectedCenter: const LatLng(1.4280748738291353, 31.75048799999998), + expectedZoom: 5.470747058151097, + ); + await testFitCoordinates( + rotation: 225, + fitCoordinates: fitCoordinates(padding: equalPadding), + expectedCenter: const LatLng(1.0175550985081157, 32.16110216543997), + expectedZoom: 5.13925271810921, + ); + await testFitCoordinates( + rotation: 270, + fitCoordinates: fitCoordinates(padding: equalPadding), + expectedCenter: const LatLng(1.4280748738291353, 31.75048799999998), + expectedZoom: 5.470747058151099, + ); + await testFitCoordinates( + rotation: 315, + fitCoordinates: fitCoordinates(padding: equalPadding), + expectedCenter: const LatLng(1.0175550985081538, 32.16110216543986), + expectedZoom: 5.13925271810921, + ); + await testFitCoordinates( + rotation: 360, + fitCoordinates: fitCoordinates(padding: equalPadding), + expectedCenter: const LatLng(1.4280748738291353, 31.75048799999998), + expectedZoom: 5.470747058151099, + ); + + // Tests with asymmetric padding + + const asymmetricPadding = EdgeInsets.fromLTRB(12, 12, 24, 24); + + await testFitCoordinates( + rotation: 45, + fitCoordinates: fitCoordinates(padding: asymmetricPadding), + expectedCenter: const LatLng(1.0175550985081665, 32.524454855645835), + expectedZoom: 5.037373104089995, + ); + await testFitCoordinates( + rotation: 90, + fitCoordinates: fitCoordinates(padding: asymmetricPadding), + expectedCenter: const LatLng(1.63218686735705, 31.954672909718134), + expectedZoom: 5.36886744413189, + ); + await testFitCoordinates( + rotation: 135, + fitCoordinates: fitCoordinates(padding: asymmetricPadding), + expectedCenter: const LatLng(1.3808275978186646, 32.16110216543989), + expectedZoom: 5.037373104089992, + ); + await testFitCoordinates( + rotation: 180, + fitCoordinates: fitCoordinates(padding: asymmetricPadding), + expectedCenter: const LatLng(1.63218686735705, 31.546303090281786), + expectedZoom: 5.3688674441318875, + ); + await testFitCoordinates( + rotation: 225, + fitCoordinates: fitCoordinates(padding: asymmetricPadding), + expectedCenter: const LatLng(1.0175550985081283, 31.797749475233953), + expectedZoom: 5.037373104089987, + ); + await testFitCoordinates( + rotation: 270, + fitCoordinates: fitCoordinates(padding: asymmetricPadding), + expectedCenter: const LatLng(1.2239447514276816, 31.546303090281786), + expectedZoom: 5.368867444131882, + ); + await testFitCoordinates( + rotation: 315, + fitCoordinates: fitCoordinates(padding: asymmetricPadding), + expectedCenter: const LatLng(0.6542416853021571, 32.16110216543989), + expectedZoom: 5.037373104089994, + ); + await testFitCoordinates( + rotation: 360, + fitCoordinates: fitCoordinates(padding: asymmetricPadding), + expectedCenter: const LatLng(1.223944751427707, 31.954672909718177), + expectedZoom: 5.368867444131889, + ); + }); + + testWidgets('test fit inside bounds with rotation', (tester) async { + final controller = MapController(); + final bounds = LatLngBounds( + const LatLng(4.214943, 33.925781), + const LatLng(-1.362176, 29.575195), + ); + + await tester.pumpWidget(TestApp(controller: controller)); + + Future testFitInsideBounds({ + required double rotation, + required CameraFit cameraConstraint, + required LatLngBounds expectedBounds, + required LatLng expectedCenter, + required double expectedZoom, + }) async { + controller.rotate(rotation); + + controller.fitCamera(cameraConstraint); + await tester.pump(); + expect( + controller.camera.visibleBounds.northWest.latitude, + moreOrLessEquals(expectedBounds.northWest.latitude), + ); + expect( + controller.camera.visibleBounds.northWest.longitude, + moreOrLessEquals(expectedBounds.northWest.longitude), + ); + expect( + controller.camera.visibleBounds.southEast.latitude, + moreOrLessEquals(expectedBounds.southEast.latitude), + ); + expect( + controller.camera.visibleBounds.southEast.longitude, + moreOrLessEquals(expectedBounds.southEast.longitude), + ); + expect( + controller.camera.center.latitude, + moreOrLessEquals(expectedCenter.latitude), + ); + expect( + controller.camera.center.longitude, + moreOrLessEquals(expectedCenter.longitude), + ); + expect(controller.camera.zoom, moreOrLessEquals(expectedZoom)); + } + + // Tests with no padding + + await testFitInsideBounds( + rotation: -360, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.6031134233301474, 29.56772762000039), + const LatLng(-0.7450743699315154, 33.9183136200004), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.014499548969527, + ); + await testFitInsideBounds( + rotation: -300, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.614278658020072, 29.56945889748712), + const LatLng(-0.7338878844415404, 33.920044897487124), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447606), + expectedZoom: 6.464483862446023, + ); + await testFitInsideBounds( + rotation: -240, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.6142786580207207, 29.56945889748632), + const LatLng(-0.7338878844408534, 33.92004489748633), + ), + expectedCenter: const LatLng(1.427411699029666, 31.747848447605964), + expectedZoom: 6.464483862446028, + ); + await testFitInsideBounds( + rotation: -180, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.6031134233301474, 29.56772762000039), + const LatLng(-0.7450743699315154, 33.9183136200004), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.014499548969527, + ); + await testFitInsideBounds( + rotation: -120, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.614278658020072, 29.56945889748712), + const LatLng(-0.7338878844415404, 33.920044897487124), + ), + expectedCenter: const LatLng(1.4274116990296914, 31.74784844760592), + expectedZoom: 6.464483862446023, + ); + await testFitInsideBounds( + rotation: -60, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.6142786580207207, 29.56945889748632), + const LatLng(-0.7338878844408534, 33.92004489748633), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.464483862446028, + ); + await testFitInsideBounds( + rotation: 0, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.6031134233301474, 29.56772762000039), + const LatLng(-0.7450743699315154, 33.9183136200004), + ), + expectedCenter: const LatLng(1.4280748738291353, 31.75048799999998), + expectedZoom: 6.014499548969527, + ); + await testFitInsideBounds( + rotation: 60, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.614278658020072, 29.56945889748712), + const LatLng(-0.7338878844415404, 33.920044897487124), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447606), + expectedZoom: 6.464483862446023, + ); + await testFitInsideBounds( + rotation: 120, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.6142786580207207, 29.56945889748632), + const LatLng(-0.7338878844408534, 33.92004489748633), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.464483862446028, + ); + await testFitInsideBounds( + rotation: 180, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.6031134233301474, 29.56772762000039), + const LatLng(-0.7450743699315154, 33.9183136200004), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.014499548969527, + ); + await testFitInsideBounds( + rotation: 240, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.614278658020072, 29.56945889748712), + const LatLng(-0.7338878844415404, 33.920044897487124), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.74784844760592), + expectedZoom: 6.464483862446023, + ); + await testFitInsideBounds( + rotation: 300, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.6142786580207207, 29.56945889748632), + const LatLng(-0.7338878844408534, 33.92004489748633), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.464483862446028, + ); + await testFitInsideBounds( + rotation: 360, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: EdgeInsets.zero, + ), + expectedBounds: LatLngBounds( + const LatLng(3.6031134233301474, 29.56772762000039), + const LatLng(-0.7450743699315154, 33.9183136200004), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.014499548969527, + ); + + // Tests with symmetric padding + + const equalPadding = EdgeInsets.all(12); + + await testFitInsideBounds( + rotation: -360, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.8971355052392727, 29.273074295454837), + const LatLng(-1.0436460563295582, 34.21692202272759), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 5.8300749778321, + ); + await testFitInsideBounds( + rotation: -300, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.900159833096254, 29.26631356795233), + const LatLng(-1.0406152150025456, 34.21016129522507), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.280059291308596, + ); + await testFitInsideBounds( + rotation: -240, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.900159833095936, 29.266313567952732), + const LatLng(-1.0406152150029018, 34.210161295225475), + ), + expectedCenter: const LatLng(1.427411699029666, 31.747848447605964), + expectedZoom: 6.280059291308594, + ); + await testFitInsideBounds( + rotation: -180, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.897135505240036, 29.273074295453956), + const LatLng(-1.0436460563287566, 34.21692202272667), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 5.830074977832107, + ); + await testFitInsideBounds( + rotation: -120, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.900159833096941, 29.26631356795153), + const LatLng(-1.0406152150018586, 34.210161295224275), + ), + expectedCenter: const LatLng(1.427411699029615, 31.74784844760592), + expectedZoom: 6.280059291308602, + ); + await testFitInsideBounds( + rotation: -60, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.9001598330968137, 29.266313567951688), + const LatLng(-1.0406152150019858, 34.21016129522444), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447606), + expectedZoom: 6.280059291308601, + ); + await testFitInsideBounds( + rotation: 0, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.921797222702341, 29.273074295454474), + const LatLng(-1.0189308220167805, 34.21692202272719), + ), + expectedCenter: const LatLng(1.4280748738291607, 31.750488000000022), + expectedZoom: 5.830074977832103, + ); + await testFitInsideBounds( + rotation: 60, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.9001598330947402, 29.26631356795413), + const LatLng(-1.0406152150040977, 34.21016129522692), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.280059291308584, + ); + await testFitInsideBounds( + rotation: 120, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.900159833097259, 29.26631356795117), + const LatLng(-1.0406152150015406, 34.21016129522388), + ), + expectedCenter: const LatLng(1.427411699029615, 31.74784844760592), + expectedZoom: 6.280059291308604, + ); + await testFitInsideBounds( + rotation: 180, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.897135505238624, 29.273074295455597), + const LatLng(-1.0436460563302323, 34.21692202272835), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 5.830074977832095, + ); + await testFitInsideBounds( + rotation: 240, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.900159833097577, 29.266313567950775), + const LatLng(-1.0406152150011843, 34.21016129522348), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.280059291308607, + ); + await testFitInsideBounds( + rotation: 300, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.900159833095936, 29.266313567952732), + const LatLng(-1.0406152150029018, 34.210161295225475), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 6.280059291308594, + ); + await testFitInsideBounds( + rotation: 360, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: equalPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.897135505240036, 29.273074295453956), + const LatLng(-1.0436460563287566, 34.21692202272667), + ), + expectedCenter: const LatLng(1.4274116990296404, 31.747848447605964), + expectedZoom: 5.830074977832107, + ); + + // Tests with asymmetric padding + + const asymmetricPadding = EdgeInsets.fromLTRB(12, 12, 24, 24); + + await testFitInsideBounds( + rotation: -360, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.93081962068567, 29.252575414633416), + const LatLng(-1.371554855609733, 34.558168097560255), + ), + expectedCenter: const LatLng(1.2682880092901039, 31.90701622809379), + expectedZoom: 5.728195363812894, + ); + await testFitInsideBounds( + rotation: -300, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(4.12285573833763, 29.236827391148324), + const LatLng(-1.179091165662991, 34.5424200740752), + ), + expectedCenter: const LatLng(1.4700469435297785, 31.90701622809379), + expectedZoom: 6.178179677289382, + ); + await testFitInsideBounds( + rotation: -240, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(4.239064535667103, 29.12030848890263), + const LatLng(-1.0625945779487183, 34.42590117182947), + ), + expectedCenter: const LatLng(1.5865243776059719, 31.790497325848776), + expectedZoom: 6.178179677289386, + ); + await testFitInsideBounds( + rotation: -180, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(4.248344214607476, 28.934239853659207), + const LatLng(-1.0532909733871119, 34.239832536586086), + ), + expectedCenter: const LatLng(1.5865243776059845, 31.58868066711818), + expectedZoom: 5.728195363812884, + ); + await testFitInsideBounds( + rotation: -120, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(4.045373737414225, 28.926110318495112), + const LatLng(-1.2567528788718025, 34.23170300142199), + ), + expectedCenter: const LatLng(1.3847756639611237, 31.58868066711814), + expectedZoom: 6.17817967728938, + ); + await testFitInsideBounds( + rotation: -60, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.9291368844072636, 29.04262922073941), + const LatLng(-1.373241074234739, 34.34822190366625), + ), + expectedCenter: const LatLng(1.2682880092901039, 31.70519956936319), + expectedZoom: 6.1781796772893856, + ); + await testFitInsideBounds( + rotation: 0, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.9308196206843724, 29.252575414634972), + const LatLng(-1.3715548556110944, 34.55816809756181), + ), + expectedCenter: const LatLng(1.2689512274367805, 31.909655780487807), + expectedZoom: 5.728195363812883, + ); + await testFitInsideBounds( + rotation: 60, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(4.122855738337325, 29.236827391148683), + const LatLng(-1.179091165663309, 34.542420074075565), + ), + expectedCenter: const LatLng(1.4700469435298167, 31.90701622809375), + expectedZoom: 6.178179677289379, + ); + await testFitInsideBounds( + rotation: 120, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(4.23906453566681, 29.12030848890299), + const LatLng(-1.0625945779490364, 34.42590117182983), + ), + expectedCenter: const LatLng(1.5865243776060227, 31.790497325848737), + expectedZoom: 6.178179677289384, + ); + await testFitInsideBounds( + rotation: 180, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(4.248344214608647, 28.934239853657804), + const LatLng(-1.053290973385865, 34.23983253658464), + ), + expectedCenter: const LatLng(1.5865243776059845, 31.58868066711818), + expectedZoom: 5.728195363812894, + ); + await testFitInsideBounds( + rotation: 240, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(4.045373737414429, 28.926110318494874), + const LatLng(-1.2567528788715607, 34.23170300142176), + ), + expectedCenter: const LatLng(1.3847756639610982, 31.58868066711814), + expectedZoom: 6.178179677289382, + ); + await testFitInsideBounds( + rotation: 300, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.9291368844075816, 29.04262922073901), + const LatLng(-1.3732410742343828, 34.348221903665845), + ), + expectedCenter: const LatLng(1.2682880092901676, 31.705199569363227), + expectedZoom: 6.178179677289388, + ); + await testFitInsideBounds( + rotation: 360, + cameraConstraint: CameraFit.insideBounds( + bounds: bounds, + padding: asymmetricPadding, + ), + expectedBounds: LatLngBounds( + const LatLng(3.9308196206847033, 29.252575414634578), + const LatLng(-1.3715548556107255, 34.55816809756141), + ), + expectedCenter: const LatLng(1.2682880092901039, 31.90701622809375), + expectedZoom: 5.728195363812886, + ); + }); } diff --git a/test/flutter_map_test.dart b/test/flutter_map_test.dart index 3f2884cfd..46a92d54d 100644 --- a/test/flutter_map_test.dart +++ b/test/flutter_map_test.dart @@ -3,12 +3,9 @@ import 'package:flutter_map/plugin_api.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; -import 'test_utils/mocks.dart'; import 'test_utils/test_app.dart'; void main() { - setupMocks(); - testWidgets('flutter_map', (tester) async { final markers = [ Marker( @@ -39,16 +36,16 @@ void main() { int builds = 0; final map = FlutterMap( - options: MapOptions( - center: const LatLng(45.5231, -122.6765), - zoom: 13, + options: const MapOptions( + initialCenter: LatLng(45.5231, -122.6765), + initialZoom: 13, ), children: [ Builder( - builder: (BuildContext context) { - final _ = FlutterMapState.of(context); + builder: (context) { + final _ = MapCamera.of(context); builds++; - return Container(); + return const SizedBox.shrink(); }, ), ], @@ -78,4 +75,158 @@ void main() { // The map should not have rebuild after the first build. expect(builds, equals(1)); }); + + testWidgets('MapCamera.of only notifies dependencies when camera changes', + (tester) async { + int buildCount = 0; + final Widget builder = Builder(builder: (BuildContext context) { + MapCamera.of(context); + buildCount++; + return const SizedBox.shrink(); + }); + + await tester.pumpWidget(TestRebuildsApp(child: builder)); + expect(buildCount, equals(1)); + + await tester.tap(find.widgetWithText(TextButton, 'Change flags')); + await tester.pumpAndSettle(); + expect(buildCount, equals(1)); + + await tester.tap(find.widgetWithText(TextButton, 'Change MapController')); + await tester.pumpAndSettle(); + expect(buildCount, equals(1)); + + await tester.tap(find.widgetWithText(TextButton, 'Change Crs')); + await tester.pumpAndSettle(); + expect(buildCount, equals(2)); + }); + + testWidgets('MapOptions.of only notifies dependencies when options change', + (tester) async { + int buildCount = 0; + final Widget builder = Builder(builder: (BuildContext context) { + MapOptions.of(context); + buildCount++; + return const SizedBox.shrink(); + }); + + await tester.pumpWidget(TestRebuildsApp(child: builder)); + expect(buildCount, equals(1)); + + await tester.tap(find.widgetWithText(TextButton, 'Change flags')); + await tester.pumpAndSettle(); + expect(buildCount, equals(2)); + + await tester.tap(find.widgetWithText(TextButton, 'Change MapController')); + await tester.pumpAndSettle(); + expect(buildCount, equals(2)); + + await tester.tap(find.widgetWithText(TextButton, 'Change Crs')); + await tester.pumpAndSettle(); + expect(buildCount, equals(3)); + }); + + testWidgets( + 'MapController.of only notifies dependencies when controller changes', + (tester) async { + int buildCount = 0; + final Widget builder = Builder(builder: (BuildContext context) { + MapController.of(context); + buildCount++; + return const SizedBox.shrink(); + }); + + await tester.pumpWidget(TestRebuildsApp(child: builder)); + expect(buildCount, equals(1)); + + await tester.tap(find.widgetWithText(TextButton, 'Change flags')); + await tester.pumpAndSettle(); + expect(buildCount, equals(1)); + + await tester.tap(find.widgetWithText(TextButton, 'Change MapController')); + await tester.pumpAndSettle(); + expect(buildCount, equals(2)); + + await tester.tap(find.widgetWithText(TextButton, 'Change Crs')); + await tester.pumpAndSettle(); + expect(buildCount, equals(2)); + }); +} + +class TestRebuildsApp extends StatefulWidget { + final Widget child; + + const TestRebuildsApp({ + super.key, + required this.child, + }); + + @override + State createState() => _TestRebuildsAppState(); +} + +class _TestRebuildsAppState extends State { + MapController _mapController = MapController(); + Crs _crs = const Epsg3857(); + int _interactiveFlags = InteractiveFlag.all; + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: FlutterMap( + mapController: _mapController, + options: MapOptions( + crs: _crs, + interactionOptions: InteractionOptions( + flags: _interactiveFlags, + ), + ), + children: [ + widget.child, + Column( + children: [ + TextButton( + onPressed: () { + setState(() { + _interactiveFlags = + InteractiveFlag.hasDrag(_interactiveFlags) + ? _interactiveFlags & ~InteractiveFlag.drag + : InteractiveFlag.all; + }); + }, + child: const Text('Change flags'), + ), + TextButton( + onPressed: () { + setState(() { + _crs = _crs == const Epsg3857() + ? const Epsg4326() + : const Epsg3857(); + }); + }, + child: const Text('Change Crs'), + ), + TextButton( + onPressed: () { + _mapController.dispose(); + setState(() { + _mapController = MapController(); + }); + }, + child: const Text('Change MapController'), + ), + ], + ), + ], + ), + ), + ); + } } diff --git a/test/layer/circle_layer_test.dart b/test/layer/circle_layer_test.dart index e69ba0906..3a2a30d42 100644 --- a/test/layer/circle_layer_test.dart +++ b/test/layer/circle_layer_test.dart @@ -1,14 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/circle_layer.dart'; +import 'package:flutter_map/src/map/widget.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; -import '../test_utils/mocks.dart'; import '../test_utils/test_app.dart'; void main() { - setupMocks(); - testWidgets('test circle marker key', (tester) async { const key = Key('c-1'); diff --git a/test/layer/marker_layer_test.dart b/test/layer/marker_layer_test.dart index db0fb1eef..0047edef9 100644 --- a/test/layer/marker_layer_test.dart +++ b/test/layer/marker_layer_test.dart @@ -1,14 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/marker_layer.dart'; +import 'package:flutter_map/src/map/widget.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; -import '../test_utils/mocks.dart'; import '../test_utils/test_app.dart'; void main() { - setupMocks(); - testWidgets('test marker key', (tester) async { const key = Key('m-1'); diff --git a/test/layer/polygon_layer_test.dart b/test/layer/polygon_layer_test.dart index ef5e46c78..4291039f5 100644 --- a/test/layer/polygon_layer_test.dart +++ b/test/layer/polygon_layer_test.dart @@ -1,14 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/polygon_layer.dart'; +import 'package:flutter_map/src/map/widget.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; -import '../test_utils/mocks.dart'; import '../test_utils/test_app.dart'; void main() { - setupMocks(); - testWidgets('test polygon layer', (tester) async { final polygons = [ for (int i = 0; i < 1; ++i) diff --git a/test/layer/polyline_layer_test.dart b/test/layer/polyline_layer_test.dart index a42816161..e2290ae70 100644 --- a/test/layer/polyline_layer_test.dart +++ b/test/layer/polyline_layer_test.dart @@ -1,14 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/polyline_layer.dart'; +import 'package:flutter_map/src/map/widget.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; -import '../test_utils/mocks.dart'; import '../test_utils/test_app.dart'; void main() { - setupMocks(); - testWidgets('test polyline layer', (tester) async { final polylines = [ for (int i = 0; i < 10; i++) diff --git a/test/layer/tile_layer/tile_bounds/crs_fakes.dart b/test/layer/tile_layer/tile_bounds/crs_fakes.dart index 594144930..6b2a02ab7 100644 --- a/test/layer/tile_layer/tile_bounds/crs_fakes.dart +++ b/test/layer/tile_layer/tile_bounds/crs_fakes.dart @@ -1,5 +1,5 @@ -import 'package:flutter_map/src/misc/point.dart'; import 'package:flutter_map/src/geo/crs.dart'; +import 'package:flutter_map/src/misc/point.dart'; import 'package:latlong2/latlong.dart'; class FakeInfiniteCrs extends Crs { diff --git a/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart b/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart index 460591669..dd986bd6a 100644 --- a/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart +++ b/test/layer/tile_layer/tile_bounds/tile_bounds_at_zoom_test.dart @@ -1,8 +1,8 @@ -import 'package:flutter_map/src/misc/private/bounds.dart'; -import 'package:flutter_map/src/misc/point.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/misc/point.dart'; +import 'package:flutter_map/src/misc/private/bounds.dart'; import 'package:test/test.dart'; void main() { diff --git a/test/misc/frame_constraint_test.dart b/test/misc/frame_constraint_test.dart new file mode 100644 index 000000000..3d02b91a4 --- /dev/null +++ b/test/misc/frame_constraint_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_map/src/geo/crs.dart'; +import 'package:flutter_map/src/geo/latlng_bounds.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/misc/point.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +void main() { + group('CameraConstraint', () { + group('contain', () { + test('rotated', () { + final mapConstraint = CameraConstraint.contain( + bounds: LatLngBounds( + const LatLng(-90, -180), + const LatLng(90, 180), + ), + ); + + final camera = MapCamera( + crs: const Epsg3857(), + center: const LatLng(-90, -180), + zoom: 1, + rotation: 45, + nonRotatedSize: const CustomPoint(200, 300), + ); + + final clamped = mapConstraint.constrain(camera)!; + + expect(clamped.zoom, 1); + expect(clamped.center.latitude, closeTo(-48.562, 0.001)); + expect(clamped.center.longitude, closeTo(-55.703, 0.001)); + }); + }); + }); +} diff --git a/test/test_utils/mocks.dart b/test/test_utils/mocks.dart deleted file mode 100644 index 8a25202e3..000000000 --- a/test/test_utils/mocks.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockHttpClientResponse extends Mock implements HttpClientResponse { - final _stream = readFile(); - - @override - int get statusCode => HttpStatus.ok; - - @override - int get contentLength => File('test/res/map.png').lengthSync(); - - @override - HttpClientResponseCompressionState get compressionState => - HttpClientResponseCompressionState.notCompressed; - - @override - StreamSubscription> listen(void Function(List event)? onData, - {Function? onError, void Function()? onDone, bool? cancelOnError}) { - return _stream.listen( - onData, - onError: onError, - onDone: onDone, - cancelOnError: cancelOnError, - ); - } - - static Stream> readFile() => File('test/res/map.png').openRead(); -} - -class MockHttpHeaders extends Mock implements HttpHeaders {} - -class MockHttpClientRequest extends Mock implements HttpClientRequest { - @override - HttpHeaders get headers => MockHttpHeaders(); - - @override - Future close() => Future.value(MockHttpClientResponse()); -} - -class MockClient extends Mock implements HttpClient { - @override - Future getUrl(Uri url) { - return Future.value(MockHttpClientRequest()); - } -} - -class MockHttpOverrides extends HttpOverrides { - @override - HttpClient createHttpClient(SecurityContext? securityContext) => MockClient(); -} - -void setupMocks() { - setUpAll(() { - HttpOverrides.global = MockHttpOverrides(); - }); -} diff --git a/test/test_utils/test_app.dart b/test/test_utils/test_app.dart index 5945ff855..ca0fc6684 100644 --- a/test/test_utils/test_app.dart +++ b/test/test_utils/test_app.dart @@ -1,5 +1,16 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/circle_layer.dart'; +import 'package:flutter_map/src/layer/marker_layer.dart'; +import 'package:flutter_map/src/layer/polygon_layer.dart'; +import 'package:flutter_map/src/layer/polyline_layer.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart'; +import 'package:flutter_map/src/map/map_controller.dart'; +import 'package:flutter_map/src/map/options.dart'; +import 'package:flutter_map/src/map/widget.dart'; import 'package:latlong2/latlong.dart'; class TestApp extends StatelessWidget { @@ -23,19 +34,20 @@ class TestApp extends StatelessWidget { return MaterialApp( home: Scaffold( body: Center( - // ensure that map is always of the same size + // Ensure that map is always of the same size child: SizedBox( width: 200, height: 200, child: FlutterMap( mapController: controller, - options: MapOptions( - center: const LatLng(45.5231, -122.6765), - zoom: 13, + options: const MapOptions( + initialCenter: LatLng(45.5231, -122.6765), + initialZoom: 13, ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + tileProvider: TestTileProvider(), ), if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), if (polygons.isNotEmpty) PolygonLayer(polygons: polygons), @@ -49,3 +61,14 @@ class TestApp extends StatelessWidget { ); } } + +class TestTileProvider extends TileProvider { + // Base 64 encoded 256x256 white tile. + static const _whiteTile = + 'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAANQTFRF////p8QbyAAAAB9JREFUeJztwQENAAAAwqD3T20ON6AAAAAAAAAAAL4NIQAAAfFnIe4AAAAASUVORK5CYII='; + + @override + ImageProvider getImage( + TileCoordinates coordinates, TileLayer options) => + MemoryImage(base64Decode(_whiteTile)); +}