From 2c60d63b48d0a5d77305b4e00dae8f981549260a Mon Sep 17 00:00:00 2001 From: Rory Stephenson Date: Thu, 6 Jul 2023 11:15:36 +0200 Subject: [PATCH] [v6] Major State Refactoring (#1551) * Split FlutterMapState in to a stateful container widget (FlutterMapStateContainer) and an immutable representation of the state of the map (FlutterMapState) * Extract interactions to InteractionDetector * Move gesture initialisation out of builder and stop passing the whole FlutterMapStateContainer to InteractionDetector * Minor tidy-ups * Re-instate linking of MapController to map state * Trigger all FlutterMapState manipulations via FlutterMapStateController * Reduce MapController API size and simplify gesture code - Replaced mapState getter with the various getters which were just proxied to MapState. - Heavy refactoring (hopefully without changing behaviour) of gesture code. In passing I have simplified * Remove unnecessary getters now that InteractiveFlags defines convenience methods for checking single flags * Fix double tap zoom not working when drag was enabled and prevent pinch move when only pinch zoom is enabled * Use new InteractiveFlag convenience methods * Combine getBoundsCenterZoom and centerZoomFitBounds * Replace http stubbing with an in-memory TileProvider in tests This stops the following message from being spammed in tests which was caused by a problem with the http mocking: type 'Null' is not a subtype of type 'Future' * Separate MapOptions from FlutterMapState In doing so I noticed that the adaptive boundary options could use a refactor and so placed them in a dedicated class which led to a tidy up to the boundary code in FlutterMapState. * Combine adaptive bounds, max bounds and sw/ne pan bounds in to a single MapBounds class * Create FrameConstraint and FrameFit abstraction FrameConstraint unites the various methods of setting a maximum bounds for the map frame. Previously MapOptions had three different concepts for setting a max bounds: adaptive bounds, maxBounds and se/nw pan boundaries. Adaptive bounds and maxBounds are now FrameConstraint.contain whilst sw/ne pan boundaries is replaced by FrameConstraint.containCenter. FrameFit is a replacement for FitBoundsOptions, combinining the options with the bounds. This means bounds/boundsOptions now become initialFrameFit (since bounds/boundsOptions were actually initial bounds and the options for those initial bounds). Additionally this sets up an abstraction for different map fits since coordinate fit will be added next. * Add deprecations on MapControllerImpl * Add fitting by coordinates This commit incorporates @jjoelson's coordinate fit implementation in to the new FrameFit abstraction. Co-authored-by: Jonathan Joelson * Rename FlutterMapState to FlutterMapFrame, add InteractionOptions collection to tidy up options and change how options are propagated in preparation for changing the inherited widget to an inherited model * Use InhertiedModel instead of InheritedWidget * Remove FitCoordinates' inside parameter because fitting inside a set of coordinates doesn't have an unambiguous meaning * Fix event names appearing minified when running web release build * Set constraints that match the old adaptive constraints These old adaptive constraints did not prevent the map from going outside of the specified bounds, they stopped the center of the map from going outside of those bounds. This commit sets the constraints appropriately. * Make documentation easier to read * Use flags/options from InteractionOptions not the old deprecated values, unless InteractiveOptions is not provided * Fix options propagation * Add tests to make sure the InheritedModel notifies if and only if the relevnat aspect changes * Rename FlutterMapFrame to MapFrame * Avoid an extra Stack * Assign AnimationControllers where they are declared * Rename from frame to camera * Remove flutter_map_ prefixes from files in lib/src/map/ * Move FlutterMapStateContainer in to FlutterMap's file since it is just the widget state * Rename mapCamera variables to camera for consistency with options for MapOptions * Documentation * Use standard deprecations format In passing re-ordered the methods in MapControllerImpl to match MapController. * Re-organized camera related source files Improved some documentation (part 1) Prevent public exposure of `FitCoordinates` and `FitBounds` constructors * Add FitInsideBounds * Move CameraFit attributes from the base class to the subclasses and tidy up documentation None of the fields which were on the CameraFit base class were conceptually essential for any imaginable camera fit. Moving them to the subclasses ensures that any future camera fits will not need to implement those options just because they already exist. It would also allow for individual camera fits to specify different default values if appropriate. * Re-order imports alphabetically * Add lint to enforce consistent import/export ordering * Reinstate maxBounds as a deprecated option * Remove duplicate exports and make imports consistent The plugin API no longer exports classes which flutter_map already exports. Imports within this package now import the actual classes they use rather than the whole flutter_map library. Internal code should not depend on the exported library definition. * Remove deprecated AnchorAlign * Add deprecation for nonrotatedSize * Fix deprecation * Set default CameraFit maxZoom values to null The other parameters for CameraFit all have default values which will not cause changes to the calculated CameraFit (i.e. padding is zero, forceIntegerZoomLevel is false). Setting maxZoom to null makes it consistent with the other parameters in that it will not affect the calculated CameraFit. I chose null over double.infinity as in my opinion the intent is clearer, no maximum. --------- Co-authored-by: Jonathan Joelson Co-authored-by: JaffaKetchup --- analysis_options.yaml | 3 +- example/lib/main.dart | 1 - .../lib/pages/animated_map_controller.dart | 28 +- example/lib/pages/circle.dart | 6 +- example/lib/pages/custom_crs/custom_crs.dart | 4 +- example/lib/pages/epsg3413_crs.dart | 4 +- example/lib/pages/epsg4326_crs.dart | 10 +- example/lib/pages/fallback_url.dart | 4 +- example/lib/pages/home.dart | 12 +- example/lib/pages/interactive_test_page.dart | 81 +- example/lib/pages/latlng_to_screen_point.dart | 21 +- example/lib/pages/many_markers.dart | 10 +- example/lib/pages/map_controller.dart | 16 +- example/lib/pages/map_inside_listview.dart | 6 +- example/lib/pages/markers.dart | 8 +- example/lib/pages/moving_markers.dart | 6 +- example/lib/pages/offline_map.dart | 10 +- example/lib/pages/overlay_image.dart | 6 +- example/lib/pages/plugin_scalebar.dart | 21 +- example/lib/pages/plugin_zoombuttons.dart | 39 +- example/lib/pages/point_to_latlng.dart | 8 +- example/lib/pages/polygon.dart | 6 +- example/lib/pages/polyline.dart | 6 +- example/lib/pages/reset_tile_layer.dart | 6 +- .../lib/pages/scale_layer_plugin_option.dart | 3 +- example/lib/pages/secondary_tap.dart | 4 +- example/lib/pages/sliding_map.dart | 13 +- example/lib/pages/stateful_markers.dart | 6 +- example/lib/pages/tile_builder_example.dart | 6 +- .../lib/pages/tile_loading_error_handle.dart | 4 +- example/lib/pages/wms_tile_layer.dart | 8 +- .../lib/pages/zoombuttons_plugin_option.dart | 27 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 - lib/flutter_map.dart | 19 +- lib/plugin_api.dart | 4 - lib/src/geo/crs.dart | 2 +- .../flutter_map_interactive_viewer.dart | 865 +++++++++++++ lib/src/gestures/gestures.dart | 851 ------------- lib/src/gestures/interactive_flag.dart | 50 +- lib/src/gestures/map_events.dart | 159 +-- lib/src/gestures/multi_finger_gesture.dart | 4 + lib/src/layer/attribution_layer/rich.dart | 6 +- lib/src/layer/attribution_layer/simple.dart | 1 - lib/src/layer/circle_layer.dart | 4 +- lib/src/layer/marker_layer.dart | 16 +- lib/src/layer/overlay_image_layer.dart | 12 +- lib/src/layer/polygon_layer.dart | 12 +- lib/src/layer/polyline_layer.dart | 12 +- lib/src/layer/tile_layer/tile.dart | 2 +- .../tile_layer/tile_bounds/tile_bounds.dart | 2 +- .../tile_bounds/tile_bounds_at_zoom.dart | 2 +- .../layer/tile_layer/tile_coordinates.dart | 2 +- .../layer/tile_layer/tile_image_manager.dart | 2 +- lib/src/layer/tile_layer/tile_layer.dart | 68 +- .../tile_provider/network_tile_provider.dart | 4 +- lib/src/layer/tile_layer/tile_range.dart | 4 +- .../tile_layer/tile_range_calculator.dart | 26 +- .../layer/tile_layer/tile_update_event.dart | 7 + .../tile_layer/tile_update_transformer.dart | 2 +- lib/src/map/camera/camera.dart | 361 ++++++ lib/src/map/camera/camera_constraint.dart | 144 +++ lib/src/map/camera/camera_fit.dart | 452 +++++++ lib/src/map/inherited_model.dart | 82 ++ lib/src/map/internal_controller.dart | 472 +++++++ .../{controller.dart => map_controller.dart} | 243 ++-- lib/src/map/map_controller_impl.dart | 230 ++++ lib/src/map/options.dart | 499 ++++++-- lib/src/map/state.dart | 850 ------------- lib/src/map/widget.dart | 166 ++- lib/src/misc/center_zoom.dart | 16 + lib/src/misc/fit_bounds_options.dart | 5 + lib/src/misc/point.dart | 5 + lib/src/misc/position.dart | 2 +- lib/src/misc/private/bounds.dart | 31 +- test/core/bounds_test.dart | 2 +- test/flutter_map_controller_test.dart | 1093 +++++++++++++++-- test/flutter_map_test.dart | 169 ++- test/layer/circle_layer_test.dart | 6 +- test/layer/marker_layer_test.dart | 6 +- test/layer/polygon_layer_test.dart | 6 +- test/layer/polyline_layer_test.dart | 6 +- .../tile_layer/tile_bounds/crs_fakes.dart | 2 +- .../tile_bounds/tile_bounds_at_zoom_test.dart | 4 +- test/misc/frame_constraint_test.dart | 36 + test/test_utils/mocks.dart | 60 - test/test_utils/test_app.dart | 33 +- 86 files changed, 4910 insertions(+), 2604 deletions(-) create mode 100644 lib/src/gestures/flutter_map_interactive_viewer.dart delete mode 100644 lib/src/gestures/gestures.dart create mode 100644 lib/src/map/camera/camera.dart create mode 100644 lib/src/map/camera/camera_constraint.dart create mode 100644 lib/src/map/camera/camera_fit.dart create mode 100644 lib/src/map/inherited_model.dart create mode 100644 lib/src/map/internal_controller.dart rename lib/src/map/{controller.dart => map_controller.dart} (61%) create mode 100644 lib/src/map/map_controller_impl.dart delete mode 100644 lib/src/map/state.dart create mode 100644 test/misc/frame_constraint_test.dart delete mode 100644 test/test_utils/mocks.dart 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)); +}