From d6b365ed7228da1e85bdbe3b19b20b313af20b81 Mon Sep 17 00:00:00 2001 From: maRci002 Date: Fri, 29 Jan 2021 18:48:36 +0100 Subject: [PATCH] WIP: Gestures update - two finger rotation / interactions filter / map events (#719) * Add two finger rotation support * Add InteractiveFlags * Add example page * Emit MapEvents basic * Update interactive test page * update interactive page * Emit move / rotate events * Emit rotation events * Rotate only when rotationThreshold reached * Update examples * add some document * fix some event's source * typo fix * Call onRotationChanged correctly * Fix arbitrary jumps when map is panned in rotated state * tap / longPress / double tap use rotated offset / fix GestureDetector corners * code refactor / organize * Wip-gesture race: time to test on real device * Fix multi finger gesture race * Reset gesture winner correctly * Use MultiFingerGesture during gesture race * update MultiFingerGesture doc * add debugMultiFingerGestureWinner / enableMultiFingerGestureRace * Do not override original start point * mapEventStream do not rely on MapController.ready * emit MapEventFlingAnimationStart correctly * remove expensive _positionedTapDetectorKey * rebuild layers just once when Move and Rotate happens at the same time * use different eventKey in example * GestureDetector isn't rotated anymore * Correct fling animation when map is rotated * Make rotation operation cheaper * add rotate flag for layers * Revert "add rotate flag for layers" This reverts commit 9e550feed4c0784e48db8a0102db215969018e74. * create rotate and non rotate layers * Use Stream rebuild instead of dynamic * #736 fix - rebuild layers when size changed with new pixelOrigin * #736 fix2 - do not call onMoveSink after init * #736 fix2 - handle rebuild layers without _updateSizeByOriginalSizeAndRotation method * fix emit MapEventMove while multifinger * try to fix dartanalyzer for Travis * try to fix dartanalyzer for Travis 2 * Update tile_layer.dart Co-authored-by: John Ryan --- .../android/app/src/main/AndroidManifest.xml | 4 + example/lib/main.dart | 2 + example/lib/pages/interactive_test_page.dart | 191 +++++ example/lib/pages/live_location.dart | 37 +- example/lib/pages/map_controller.dart | 95 ++- example/lib/pages/on_tap.dart | 8 +- example/lib/pages/plugin_api.dart | 4 +- example/lib/pages/plugin_scalebar.dart | 12 +- example/lib/pages/plugin_zoombuttons.dart | 14 +- .../lib/pages/scale_layer_plugin_option.dart | 2 +- example/lib/pages/widgets.dart | 4 +- .../lib/pages/zoombuttons_plugin_option.dart | 2 +- example/lib/widgets/drawer.dart | 7 + lib/flutter_map.dart | 135 +++- lib/src/core/point.dart | 14 + lib/src/gestures/gestures.dart | 662 +++++++++++++++--- lib/src/gestures/interactive_flag.dart | 36 + lib/src/gestures/map_events.dart | 180 +++++ lib/src/gestures/multi_finger_gesture.dart | 26 + lib/src/layer/circle_layer.dart | 4 +- lib/src/layer/group_layer.dart | 13 +- lib/src/layer/marker_layer.dart | 4 +- lib/src/layer/overlay_image_layer.dart | 4 +- lib/src/layer/polygon_layer.dart | 5 +- lib/src/layer/polyline_layer.dart | 4 +- lib/src/layer/tile_layer.dart | 13 +- lib/src/map/flutter_map_state.dart | 172 ++--- lib/src/map/map.dart | 223 +++++- lib/src/map/map_state_widget.dart | 9 +- 29 files changed, 1561 insertions(+), 325 deletions(-) create mode 100644 example/lib/pages/interactive_test_page.dart create mode 100644 lib/src/gestures/interactive_flag.dart create mode 100644 lib/src/gestures/map_events.dart create mode 100644 lib/src/gestures/multi_finger_gesture.dart diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index a5b43c2be..32aeb8008 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ In most cases you can leave this as-is, but you if you want to provide additional functionality it is fine to subclass or reimplement FlutterApplication and put your custom class here. --> + + + + runApp(MyApp()); @@ -56,6 +57,7 @@ class MyApp extends StatelessWidget { CustomCrsPage.route: (context) => CustomCrsPage(), LiveLocationPage.route: (context) => LiveLocationPage(), TileLoadingErrorHandle.route: (context) => TileLoadingErrorHandle(), + InteractiveTestPage.route: (context) => InteractiveTestPage(), }, ); } diff --git a/example/lib/pages/interactive_test_page.dart b/example/lib/pages/interactive_test_page.dart new file mode 100644 index 000000000..18dd7da8d --- /dev/null +++ b/example/lib/pages/interactive_test_page.dart @@ -0,0 +1,191 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong/latlong.dart'; + +import '../widgets/drawer.dart'; + +class InteractiveTestPage extends StatefulWidget { + static const String route = 'interactive_test_page'; + + @override + State createState() { + return _InteractiveTestPageState(); + } +} + +class _InteractiveTestPageState extends State { + MapController mapController; + + // Enable pinchZoom and doubleTapZoomBy by default + int flags = InteractiveFlag.pinchZoom | InteractiveFlag.doubleTapZoom; + + StreamSubscription subscription; + + @override + void initState() { + super.initState(); + mapController = MapController(); + + subscription = mapController.mapEventStream.listen(onMapEvent); + } + + @override + void dispose() { + subscription.cancel(); + + super.dispose(); + } + + void onMapEvent(MapEvent mapEvent) { + if (mapEvent is! MapEventMove && mapEvent is! MapEventRotate) { + // do not flood console with move and rotate events + print(mapEvent); + } + } + + void updateFlags(int flag) { + if (InteractiveFlag.hasFlag(flags, flag)) { + // remove flag from flags + flags &= ~flag; + } else { + // add flag to flags + flags |= flag; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Test out Interactive flags!')), + drawer: buildDrawer(context, InteractiveTestPage.route), + body: Padding( + padding: EdgeInsets.all(8.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MaterialButton( + child: Text('Drag'), + color: InteractiveFlag.hasFlag(flags, InteractiveFlag.drag) + ? Colors.greenAccent + : Colors.redAccent, + onPressed: () { + setState(() { + updateFlags(InteractiveFlag.drag); + }); + }, + ), + MaterialButton( + child: Text('Fling'), + color: InteractiveFlag.hasFlag( + flags, InteractiveFlag.flingAnimation) + ? Colors.greenAccent + : Colors.redAccent, + onPressed: () { + setState(() { + updateFlags(InteractiveFlag.flingAnimation); + }); + }, + ), + MaterialButton( + child: Text('Pinch move'), + color: + InteractiveFlag.hasFlag(flags, InteractiveFlag.pinchMove) + ? Colors.greenAccent + : Colors.redAccent, + onPressed: () { + setState(() { + updateFlags(InteractiveFlag.pinchMove); + }); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MaterialButton( + child: Text('Double tap zoom'), + color: InteractiveFlag.hasFlag( + flags, InteractiveFlag.doubleTapZoom) + ? Colors.greenAccent + : Colors.redAccent, + onPressed: () { + setState(() { + updateFlags(InteractiveFlag.doubleTapZoom); + }); + }, + ), + MaterialButton( + child: Text('Rotate'), + color: InteractiveFlag.hasFlag(flags, InteractiveFlag.rotate) + ? Colors.greenAccent + : Colors.redAccent, + onPressed: () { + setState(() { + updateFlags(InteractiveFlag.rotate); + }); + }, + ), + MaterialButton( + child: Text('Pinch zoom'), + color: + InteractiveFlag.hasFlag(flags, InteractiveFlag.pinchZoom) + ? Colors.greenAccent + : Colors.redAccent, + onPressed: () { + setState(() { + updateFlags(InteractiveFlag.pinchZoom); + }); + }, + ), + ], + ), + Padding( + padding: EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Center( + child: StreamBuilder( + stream: mapController.mapEventStream, + builder: + (BuildContext context, AsyncSnapshot snapshot) { + if (!snapshot.hasData) { + return Text( + 'Current event: none\nSource: none', + textAlign: TextAlign.center, + ); + } + + return Text( + 'Current event: ${snapshot.data.runtimeType}\nSource: ${snapshot.data.source}', + textAlign: TextAlign.center, + ); + }, + ), + ), + ), + Flexible( + child: FlutterMap( + mapController: mapController, + options: MapOptions( + center: LatLng(51.5, -0.09), + zoom: 11.0, + interactiveFlags: flags, + ), + layers: [ + TileLayerOptions( + urlTemplate: + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + subdomains: ['a', 'b', 'c'], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/pages/live_location.dart b/example/lib/pages/live_location.dart index 9d1c3aa1d..9da283631 100644 --- a/example/lib/pages/live_location.dart +++ b/example/lib/pages/live_location.dart @@ -4,6 +4,8 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong/latlong.dart'; import 'package:location/location.dart'; +import '../widgets/drawer.dart'; + class LiveLocationPage extends StatefulWidget { static const String route = '/live_location'; @@ -15,11 +17,13 @@ class _LiveLocationPageState extends State { LocationData _currentLocation; MapController _mapController; - bool _liveUpdate = true; + bool _liveUpdate = false; bool _permission = false; String _serviceError = ''; + var interActiveFlags = InteractiveFlag.all; + final Location _locationService = Location(); @override @@ -114,7 +118,7 @@ class _LiveLocationPageState extends State { return Scaffold( appBar: AppBar(title: Text('Home')), - //drawer: buildDrawer(context, route), + drawer: buildDrawer(context, LiveLocationPage.route), body: Padding( padding: EdgeInsets.all(8.0), child: Column( @@ -135,6 +139,7 @@ class _LiveLocationPageState extends State { center: LatLng(currentLatLng.latitude, currentLatLng.longitude), zoom: 5.0, + interactiveFlags: interActiveFlags, ), layers: [ TileLayerOptions( @@ -153,10 +158,30 @@ class _LiveLocationPageState extends State { ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: () => _liveUpdate = !_liveUpdate, - child: _liveUpdate ? Icon(Icons.location_on) : Icon(Icons.location_off), - ), + floatingActionButton: Builder(builder: (BuildContext context) { + return FloatingActionButton( + onPressed: () { + setState(() { + _liveUpdate = !_liveUpdate; + + if (_liveUpdate) { + interActiveFlags = InteractiveFlag.rotate | + InteractiveFlag.pinchZoom | + InteractiveFlag.doubleTapZoom; + + Scaffold.of(context).showSnackBar(SnackBar( + content: Text( + 'In live update mode only zoom and rotation are enable'), + )); + } else { + interActiveFlags = InteractiveFlag.all; + } + }); + }, + child: + _liveUpdate ? Icon(Icons.location_on) : Icon(Icons.location_off), + ); + }), ); } } diff --git a/example/lib/pages/map_controller.dart b/example/lib/pages/map_controller.dart index 4bd1d25e1..6cffea14b 100644 --- a/example/lib/pages/map_controller.dart +++ b/example/lib/pages/map_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong/latlong.dart'; @@ -15,7 +17,6 @@ class MapControllerPage extends StatefulWidget { } class MapControllerPageState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); static LatLng london = LatLng(51.5, -0.09); static LatLng paris = LatLng(48.8566, 2.3522); static LatLng dublin = LatLng(53.3498, -6.2603); @@ -64,7 +65,6 @@ class MapControllerPageState extends State { ]; return Scaffold( - key: _scaffoldKey, appBar: AppBar(title: Text('MapController')), drawer: buildDrawer(context, MapControllerPage.route), body: Padding( @@ -116,22 +116,24 @@ class MapControllerPageState extends State { ); }, ), - MaterialButton( - child: Text('Get Bounds'), - onPressed: () { - final bounds = mapController.bounds; - - _scaffoldKey.currentState.showSnackBar(SnackBar( - content: Text( - 'Map bounds: \n' - 'E: ${bounds.east} \n' - 'N: ${bounds.north} \n' - 'W: ${bounds.west} \n' - 'S: ${bounds.south}', - ), - )); - }, - ), + Builder(builder: (BuildContext context) { + return MaterialButton( + child: Text('Get Bounds'), + onPressed: () { + final bounds = mapController.bounds; + + Scaffold.of(context).showSnackBar(SnackBar( + content: Text( + 'Map bounds: \n' + 'E: ${bounds.east} \n' + 'N: ${bounds.north} \n' + 'W: ${bounds.west} \n' + 'S: ${bounds.south}', + ), + )); + }, + ); + }), Text('Rotation:'), Expanded( child: Slider( @@ -187,27 +189,58 @@ class CurrentLocation extends StatefulWidget { } class _CurrentLocationState extends State { + int _eventKey = 0; + var icon = Icons.gps_not_fixed; + StreamSubscription mapEventSubscription; + + @override + void initState() { + super.initState(); + + mapEventSubscription = + widget.mapController.mapEventStream.listen(onMapEvent); + } + + @override + void dispose() { + mapEventSubscription.cancel(); + super.dispose(); + } + + void setIcon(IconData newIcon) { + if (newIcon != icon && mounted) { + setState(() { + icon = newIcon; + }); + } + } + + void onMapEvent(MapEvent mapEvent) { + if (mapEvent is MapEventMove && mapEvent.id == _eventKey.toString()) { + setIcon(Icons.gps_not_fixed); + } + } void _moveToCurrent() async { + _eventKey++; var location = Location(); try { var currentLocation = await location.getLocation(); - widget.mapController.move( - LatLng(currentLocation.latitude, currentLocation.longitude), 18); - - setState(() { - icon = Icons.gps_fixed; - }); - await widget.mapController.position.first; - setState(() { - icon = Icons.gps_not_fixed; - }); + var moved = widget.mapController.move( + LatLng(currentLocation.latitude, currentLocation.longitude), + 18, + id: _eventKey.toString(), + ); + + if (moved) { + setIcon(Icons.gps_fixed); + } else { + setIcon(Icons.gps_not_fixed); + } } catch (e) { - setState(() { - icon = Icons.gps_off; - }); + setIcon(Icons.gps_off); } } diff --git a/example/lib/pages/on_tap.dart b/example/lib/pages/on_tap.dart index 71ccb546f..997702912 100644 --- a/example/lib/pages/on_tap.dart +++ b/example/lib/pages/on_tap.dart @@ -14,7 +14,6 @@ class OnTapPage extends StatefulWidget { } class OnTapPageState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); static LatLng london = LatLng(51.5, -0.09); static LatLng paris = LatLng(48.8566, 2.3522); static LatLng dublin = LatLng(53.3498, -6.2603); @@ -29,7 +28,7 @@ class OnTapPageState extends State { builder: (ctx) => Container( child: GestureDetector( onTap: () { - _scaffoldKey.currentState.showSnackBar(SnackBar( + Scaffold.of(ctx).showSnackBar(SnackBar( content: Text('Tapped on blue FlutterLogo Marker'), )); }, @@ -43,7 +42,7 @@ class OnTapPageState extends State { builder: (ctx) => Container( child: GestureDetector( onTap: () { - _scaffoldKey.currentState.showSnackBar(SnackBar( + Scaffold.of(ctx).showSnackBar(SnackBar( content: Text('Tapped on green FlutterLogo Marker'), )); }, @@ -59,7 +58,7 @@ class OnTapPageState extends State { builder: (ctx) => Container( child: GestureDetector( onTap: () { - _scaffoldKey.currentState.showSnackBar(SnackBar( + Scaffold.of(ctx).showSnackBar(SnackBar( content: Text('Tapped on purple FlutterLogo Marker'), )); }, @@ -69,7 +68,6 @@ class OnTapPageState extends State { ]; return Scaffold( - key: _scaffoldKey, appBar: AppBar(title: Text('OnTap')), drawer: buildDrawer(context, OnTapPage.route), body: Padding( diff --git a/example/lib/pages/plugin_api.dart b/example/lib/pages/plugin_api.dart index aba4062ce..50af71834 100644 --- a/example/lib/pages/plugin_api.dart +++ b/example/lib/pages/plugin_api.dart @@ -30,6 +30,8 @@ class PluginPage extends StatelessWidget { urlTemplate: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', subdomains: ['a', 'b', 'c']), + ], + nonRotatedLayers: [ MyCustomPluginOptions(text: "I'm a plugin!"), ], ), @@ -46,7 +48,7 @@ class MyCustomPluginOptions extends LayerOptions { MyCustomPluginOptions({ Key key, this.text = '', - rebuild, + Stream rebuild, }) : super(key: key, rebuild: rebuild); } diff --git a/example/lib/pages/plugin_scalebar.dart b/example/lib/pages/plugin_scalebar.dart index 8e9959086..cfcb8c2e6 100644 --- a/example/lib/pages/plugin_scalebar.dart +++ b/example/lib/pages/plugin_scalebar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/plugin_api.dart'; import 'package:latlong/latlong.dart'; + import '../widgets/drawer.dart'; import 'scale_layer_plugin_option.dart'; @@ -27,15 +28,18 @@ class PluginScaleBar extends StatelessWidget { ), layers: [ TileLayerOptions( - urlTemplate: - 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - subdomains: ['a', 'b', 'c']), + urlTemplate: + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + subdomains: ['a', 'b', 'c'], + ), + ], + nonRotatedLayers: [ ScaleLayerPluginOption( lineColor: Colors.blue, lineWidth: 2, textStyle: TextStyle(color: Colors.blue, fontSize: 12), padding: EdgeInsets.all(10), - ) + ), ], ), ), diff --git a/example/lib/pages/plugin_zoombuttons.dart b/example/lib/pages/plugin_zoombuttons.dart index 0928a1ceb..e48886668 100644 --- a/example/lib/pages/plugin_zoombuttons.dart +++ b/example/lib/pages/plugin_zoombuttons.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/plugin_api.dart'; import 'package:latlong/latlong.dart'; + import '../widgets/drawer.dart'; import 'zoombuttons_plugin_option.dart'; @@ -32,12 +33,15 @@ class PluginZoomButtons extends StatelessWidget { subdomains: ['a', 'b', 'c'], tileProvider: NonCachingNetworkTileProvider(), ), + ], + nonRotatedLayers: [ ZoomButtonsPluginOption( - minZoom: 4, - maxZoom: 19, - mini: true, - padding: 10, - alignment: Alignment.bottomRight) + minZoom: 4, + maxZoom: 19, + mini: true, + padding: 10, + alignment: Alignment.bottomRight, + ), ], ), ), diff --git a/example/lib/pages/scale_layer_plugin_option.dart b/example/lib/pages/scale_layer_plugin_option.dart index b5bdfd88d..14bdf1201 100644 --- a/example/lib/pages/scale_layer_plugin_option.dart +++ b/example/lib/pages/scale_layer_plugin_option.dart @@ -19,7 +19,7 @@ class ScaleLayerPluginOption extends LayerOptions { this.lineColor = Colors.white, this.lineWidth = 2, this.padding, - rebuild, + Stream rebuild, }) : super(key: key, rebuild: rebuild); } diff --git a/example/lib/pages/widgets.dart b/example/lib/pages/widgets.dart index 327e5312b..939d3e10f 100644 --- a/example/lib/pages/widgets.dart +++ b/example/lib/pages/widgets.dart @@ -28,7 +28,7 @@ class WidgetsPage extends StatelessWidget { ZoomButtonsPlugin(), ], ), - layers: [ + nonRotatedLayers: [ ZoomButtonsPluginOption( minZoom: 4, maxZoom: 19, @@ -46,6 +46,8 @@ class WidgetsPage extends StatelessWidget { ), ), MovingWithoutRefreshAllMapMarkers(), + ], + nonRotatedChildren: [ Text( 'Plugin is just Text widget', style: TextStyle( diff --git a/example/lib/pages/zoombuttons_plugin_option.dart b/example/lib/pages/zoombuttons_plugin_option.dart index 014e20571..53b0f4145 100644 --- a/example/lib/pages/zoombuttons_plugin_option.dart +++ b/example/lib/pages/zoombuttons_plugin_option.dart @@ -28,7 +28,7 @@ class ZoomButtonsPluginOption extends LayerOptions { this.zoomOutColor, this.zoomOutColorIcon, this.zoomOutIcon = Icons.zoom_out, - rebuild, + Stream rebuild, }) : super(key: key, rebuild: rebuild); } diff --git a/example/lib/widgets/drawer.dart b/example/lib/widgets/drawer.dart index f51a56ded..2953f641e 100644 --- a/example/lib/widgets/drawer.dart +++ b/example/lib/widgets/drawer.dart @@ -5,6 +5,7 @@ import '../pages/circle.dart'; import '../pages/custom_crs/custom_crs.dart'; import '../pages/esri.dart'; import '../pages/home.dart'; +import '../pages/interactive_test_page.dart'; import '../pages/live_location.dart'; import '../pages/map_controller.dart'; import '../pages/marker_anchor.dart'; @@ -178,6 +179,12 @@ Drawer buildDrawer(BuildContext context, String currentRoute) { context, TileLoadingErrorHandle.route); }, ), + _buildMenuItem( + context, + const Text('Interactive flags test page'), + InteractiveTestPage.route, + currentRoute, + ), ], ), ); diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 31d4d3f87..e98dee371 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -6,6 +6,9 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/geo/crs/crs.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/flutter_map_state.dart'; import 'package:flutter_map/src/map/map.dart'; import 'package:flutter_map/src/plugins/plugin.dart'; @@ -14,6 +17,9 @@ import 'package:latlong/latlong.dart'; export 'package:flutter_map/src/core/point.dart'; export 'package:flutter_map/src/geo/crs/crs.dart'; export 'package:flutter_map/src/geo/latlng_bounds.dart'; +export 'package:flutter_map/src/gestures/interactive_flag.dart'; +export 'package:flutter_map/src/gestures/map_events.dart'; +export 'package:flutter_map/src/gestures/multi_finger_gesture.dart'; export 'package:flutter_map/src/layer/circle_layer.dart'; export 'package:flutter_map/src/layer/group_layer.dart'; export 'package:flutter_map/src/layer/layer.dart'; @@ -35,11 +41,24 @@ class FlutterMap extends StatefulWidget { /// /// Usually a list of [TileLayerOptions], [MarkerLayerOptions] and /// [PolylineLayerOptions]. + /// + /// These layers will render above [children] final List layers; + /// These layers won't be rotated. + /// Usually these are plugins which are floating above [layers] + /// + /// These layers will render above [nonRotatedChildren] + final List nonRotatedLayers; + /// A set of layers' widgets to used to create the layers on the map. final List children; + /// These layers won't be rotated. + /// + /// These layers will render above [layers] + final List nonRotatedChildren; + /// [MapOptions] to create a [MapState] with. /// /// This property must not be null. @@ -52,9 +71,12 @@ class FlutterMap extends StatefulWidget { Key key, @required this.options, this.layers = const [], + this.nonRotatedLayers = const [], this.children = const [], + this.nonRotatedChildren = const [], MapController mapController, - }) : _mapController = mapController ?? MapController(), + }) : assert(options != null, 'MapOptions cannot be null!'), + _mapController = mapController, super(key: key); @override @@ -69,10 +91,29 @@ class FlutterMap extends StatefulWidget { /// It also provides current map properties. abstract class MapController { /// Moves the map to a specific location and zoom level - void move(LatLng center, double zoom); + /// + /// Optionally provide [id] attribute and if you listen to [mapEventStream] later + /// a [MapEventMove] event will be emitted (if move was success) with same [id] attribute. + /// Event's source attribute will be [MapEventSource.mapController]. + /// + /// returns `true` if move was success + /// (for example it won't be success if navigating to same place with same zoom + /// or if center is out of bounds and [slideOnBoundaries] isn't enabled) + bool move(LatLng center, double zoom, {String id}); /// Sets the map rotation to a certain degrees angle (in decimal). - void rotate(double degree); + /// + /// Optionally provide [id] attribute and if you listen to [mapEventStream] later + /// a [MapEventRotate] event will be emitted (if rotate was success) with same [id] attribute. + /// Event's source attribute will be [MapEventSource.mapController]. + /// + /// returns `true` if rotate was success + /// (it won't be success if rotate is same as the old rotate) + bool rotate(double degree, {String id}); + + /// Calls [move] and [rotate] together however layers will rebuild just once instead of twice + MoveAndRotateResult moveAndRotate(LatLng center, double zoom, double degree, + {String id}); /// Fits the map bounds. Optional constraints can be defined /// through the [options] parameter. @@ -88,9 +129,9 @@ abstract class MapController { double get zoom; - ValueChanged onRotationChanged; + double get rotation; - Stream get position; + Stream get mapEventStream; factory MapController() => MapControllerImpl(); } @@ -122,12 +163,55 @@ class MapOptions { final Crs crs; final double zoom; final double rotation; + + /// Prints multi finger gesture winner + /// Helps to fine adjust [rotationThreshold] and [pinchZoomThreshold] and [pinchMoveThreshold] + /// Note: only takes effect if [enableMultiFingerGestureRace] is true + final bool debugMultiFingerGestureWinner; + + /// If true then [rotationThreshold] and [pinchZoomThreshold] and [pinchMoveThreshold] will race + /// If multiple gestures win at the same time then precedence: [pinchZoomWinGestures] > [rotationWinGestures] > [pinchMoveWinGestures] + final bool enableMultiFingerGestureRace; + + /// Rotation threshold in degree default is 20.0 + /// Map starts to rotate when [rotationThreshold] has been achieved or another multi finger gesture wins which allows [MultiFingerGesture.rotate] + /// Note: if [interactiveFlags] doesn't contain [InteractiveFlag.rotate] or [enableMultiFingerGestureRace] is false then rotate cannot win + final double rotationThreshold; + + /// When [rotationThreshold] wins over [pinchZoomThreshold] and [pinchMoveThreshold] then [rotationWinGestures] gestures will be used. + /// By default only [MultiFingerGesture.rotate] gesture will take effect see [MultiFingerGesture] for custom settings + final int rotationWinGestures; + + /// Pinch Zoom threshold default is 0.5 + /// Map starts to zoom when [pinchZoomThreshold] has been achieved or another multi finger gesture wins which allows [MultiFingerGesture.pinchZoom] + /// Note: if [interactiveFlags] doesn't contain [InteractiveFlag.pinchZoom] or [enableMultiFingerGestureRace] is false then zoom cannot win + final double pinchZoomThreshold; + + /// When [pinchZoomThreshold] wins over [rotationThreshold] and [pinchMoveThreshold] then [pinchZoomWinGestures] gestures will be used. + /// By default [MultiFingerGesture.pinchZoom] and [MultiFingerGesture.pinchMove] gestures will take effect see [MultiFingerGesture] for custom settings + final int pinchZoomWinGestures; + + /// Pinch Move threshold default is 40.0 (note: this doesn't take any effect on drag) + /// Map starts to move when [pinchMoveThreshold] has been achieved or another multi finger gesture wins which allows [MultiFingerGesture.pinchMove] + /// Note: if [interactiveFlags] doesn't contain [InteractiveFlag.pinchMove] or [enableMultiFingerGestureRace] is false then pinch move cannot win + final double pinchMoveThreshold; + + /// When [pinchMoveThreshold] wins over [rotationThreshold] and [pinchZoomThreshold] then [pinchMoveWinGestures] gestures will be used. + /// By default [MultiFingerGesture.pinchMove] and [MultiFingerGesture.pinchZoom] gestures will take effect see [MultiFingerGesture] for custom settings + final int pinchMoveWinGestures; + final double minZoom; final double maxZoom; @deprecated final bool debug; // TODO no usage outside of constructor. Marked for removal? + @Deprecated('use interactiveFlags instead') final bool interactive; + + /// see [InteractiveFlag] for custom settings + final int interactiveFlags; + final bool allowPanning; + final TapCallback onTap; final LongPressCallback onLongPress; final PositionCallback onPositionChanged; @@ -136,26 +220,38 @@ class MapOptions { final Size screenSize; final bool adaptiveBoundaries; final MapController controller; - LatLng center; - LatLngBounds bounds; - FitBoundsOptions boundsOptions; - LatLng swPanBoundary; - LatLng nePanBoundary; + final LatLng center; + final LatLngBounds bounds; + final FitBoundsOptions boundsOptions; + final LatLng swPanBoundary; + final LatLng nePanBoundary; _SafeArea _safeAreaCache; double _safeAreaZoom; MapOptions({ this.crs = const Epsg3857(), - this.center, + LatLng center, this.bounds, this.boundsOptions = const FitBoundsOptions(), this.zoom = 13.0, this.rotation = 0.0, + this.debugMultiFingerGestureWinner = false, + this.enableMultiFingerGestureRace = false, + this.rotationThreshold = 20.0, + this.rotationWinGestures = MultiFingerGesture.rotate, + this.pinchZoomThreshold = 0.5, + this.pinchZoomWinGestures = + MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, + this.pinchMoveThreshold = 40.0, + this.pinchMoveWinGestures = + MultiFingerGesture.pinchZoom | MultiFingerGesture.pinchMove, this.minZoom, this.maxZoom, this.debug = false, - this.interactive = true, + this.interactive, + /// TODO: when [interactive] is removed change this to [this.interactiveFlags = InteractiveFlag.all] and remove [interactiveFlags] from initializer list + int interactiveFlags, this.allowPanning = true, this.onTap, this.onLongPress, @@ -167,8 +263,12 @@ class MapOptions { this.controller, this.swPanBoundary, this.nePanBoundary, - }) { - center ??= LatLng(50.5, 30.51); + }) : interactiveFlags = interactiveFlags ?? + (interactive == false ? InteractiveFlag.none : InteractiveFlag.all), + center = center ?? LatLng(50.5, 30.51), + assert(rotationThreshold >= 0.0), + assert(pinchZoomThreshold >= 0.0), + assert(pinchMoveThreshold >= 0.0) { _safeAreaZoom = zoom; assert(slideOnBoundaries || !isOutOfBounds(center)); //You cannot start outside pan boundary @@ -288,3 +388,10 @@ class _SafeArea { : point.longitude.clamp(bounds.west, bounds.east), ); } + +class MoveAndRotateResult { + final bool moveSuccess; + final bool rotateSuccess; + + MoveAndRotateResult(this.moveSuccess, this.rotateSuccess); +} diff --git a/lib/src/core/point.dart b/lib/src/core/point.dart index 3830d1ef9..de28370b1 100644 --- a/lib/src/core/point.dart +++ b/lib/src/core/point.dart @@ -48,6 +48,20 @@ class CustomPoint extends math.Point { return CustomPoint(x * n, y * n); } + // Clockwise rotation + CustomPoint rotate(num radians) { + if (radians != 0.0) { + final cos = math.cos(radians); + final sin = math.sin(radians); + final nx = (cos * x) + (sin * y); + final ny = (cos * y) - (sin * x); + + return CustomPoint(nx, ny); + } + + return this; + } + @override String toString() => 'CustomPoint ($x, $y)'; } diff --git a/lib/src/gestures/gestures.dart b/lib/src/gestures/gestures.dart index 28e1dfc44..0b706d302 100644 --- a/lib/src/gestures/gestures.dart +++ b/lib/src/gestures/gestures.dart @@ -3,24 +3,42 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/gestures/interactive_flag.dart'; import 'package:flutter_map/src/gestures/latlng_tween.dart'; import 'package:flutter_map/src/map/map.dart'; import 'package:latlong/latlong.dart'; import 'package:positioned_tap_detector/positioned_tap_detector.dart'; -import 'package:vector_math/vector_math_64.dart'; abstract class MapGestureMixin extends State with TickerProviderStateMixin { static const double _kMinFlingVelocity = 800.0; + var _dragMode = false; + var _gestureWinner = MultiFingerGesture.none; + + var _pointerCounter = 0; + void savePointer(PointerEvent event) => ++_pointerCounter; + void removePointer(PointerEvent event) => --_pointerCounter; + + 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 + double _scaleCorrector; + + double _lastRotation; + double _lastScale; + Offset _lastFocalLocal; + LatLng _mapCenterStart; double _mapZoomStart; - LatLng _focalStartGlobal; - CustomPoint _focalStartLocal; + Offset _focalStartLocal; - AnimationController _controller; + AnimationController _flingController; Animation _flingAnimation; - Offset _flingOffset = Offset.zero; AnimationController _doubleTapController; Animation _doubleTapZoomAnimation; @@ -32,31 +50,177 @@ abstract class MapGestureMixin extends State @override FlutterMap get widget; MapState get mapState; - MapState get map => mapState; MapOptions get options; @override void initState() { super.initState(); - _controller = AnimationController(vsync: this) - ..addListener(_handleFlingAnimation); + _flingController = AnimationController(vsync: this) + ..addListener(_handleFlingAnimation) + ..addStatusListener(_flingAnimationStatusListener); _doubleTapController = AnimationController(vsync: this, duration: Duration(milliseconds: 200)) - ..addListener(_handleDoubleTapZoomAnimation); + ..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(); + + mapState.emitMapEvent( + MapEventFlingAnimationEnd( + center: mapState.center, zoom: mapState.zoom, source: source), + ); + } + } + + void closeDoubleTapController(MapEventSource source) { + if (_doubleTapController.isAnimating) { + _doubleTapController.stop(); + + mapState.emitMapEvent( + MapEventDoubleTapZoomEnd( + center: mapState.center, zoom: mapState.zoom, source: source), + ); + } } void handleScaleStart(ScaleStartDetails details) { - setState(() { - _mapZoomStart = map.zoom; - _mapCenterStart = map.center; + _dragMode = _pointerCounter == 1; - // determine the focal point within the widget - final focalOffset = details.localFocalPoint; - _focalStartLocal = _offsetToPoint(focalOffset); - _focalStartGlobal = _offsetToCrs(focalOffset); + final eventSource = _dragMode + ? MapEventSource.dragStart + : MapEventSource.multiFingerGestureStart; + closeFlingAnimationController(eventSource); + closeDoubleTapController(eventSource); - _controller.stop(); - }); + _gestureWinner = MultiFingerGesture.none; + + _mapZoomStart = mapState.zoom; + _mapCenterStart = mapState.center; + _focalStartLocal = _lastFocalLocal = details.localFocalPoint; + + _dragStarted = false; + _pinchZoomStarted = false; + _pinchMoveStarted = false; + _rotationStarted = false; + + _lastRotation = 0.0; + _scaleCorrector = 0.0; + _lastScale = 1.0; } void handleScaleUpdate(ScaleUpdateDetails details) { @@ -65,98 +229,336 @@ abstract class MapGestureMixin extends State return; } - setState(() { - final focalOffset = _offsetToPoint(details.localFocalPoint); - final newZoom = _getZoomForScale(_mapZoomStart, details.scale); - final focalStartPt = map.project(_focalStartGlobal, newZoom); - final newCenterPt = focalStartPt - focalOffset + map.size / 2.0; - final newCenter = map.unproject(newCenterPt, newZoom); - if (options.allowPanning) { - map.move(newCenter, newZoom, hasGesture: true); - } else { - map.move(map.center, newZoom, hasGesture: true); + 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); + var 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, + ); } - _flingOffset = _pointToOffset(_focalStartLocal - focalOffset); - }); + } 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) { + print('Multi Finger Gesture winner: Pinch Zoom'); + } + _yieldMultiFingerGestureWinner(MultiFingerGesture.pinchZoom, true); + } else if (hasIntRotate && + currentRotation.abs() >= options.rotationThreshold) { + if (options.debugMultiFingerGestureWinner) { + print('Multi Finger Gesture winner: Rotate'); + } + _yieldMultiFingerGestureWinner(MultiFingerGesture.rotate, true); + } else if (hasIntPinchMove && + (_focalStartLocal - focalOffset).distance >= + options.pinchMoveThreshold) { + if (options.debugMultiFingerGestureWinner) { + print('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; + if (hasZoom) { + 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 (_pinchMoveStarted) { + final oldCenterPt = mapState.project(mapState.center, newZoom); + final localDistanceOffset = + _rotateOffset(_lastFocalLocal - focalOffset); + + final newCenterPt = + oldCenterPt + _offsetToPoint(localDistanceOffset); + newCenter = mapState.unproject(newCenterPt, newZoom); + } else { + newCenter = mapState.center; + } + } else { + newCenter = mapState.center; + } + + if (_pinchZoomStarted || _pinchMoveStarted) { + mapMoved = mapState.move( + newCenter, + newZoom, + hasGesture: true, + callOnMoveSink: false, + 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; + mapRotated = mapState.rotate( + mapState.rotation + rotationDiff, + hasGesture: true, + callOnMoveSink: false, + source: eventSource, + ); + } + } + + if (mapMoved || mapRotated) { + mapState.rebuildLayers(); + } + } + } + } + + _lastRotation = currentRotation; + _lastScale = details.scale; + _lastFocalLocal = focalOffset; } void handleScaleEnd(ScaleEndDetails details) { _resetDoubleTapHold(); - + if (!options.allowPanning) { return; } + + 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, + ), + ); + } + + var hasFling = InteractiveFlag.hasFlag( + options.interactiveFlags, InteractiveFlag.flingAnimation); + var magnitude = details.velocity.pixelsPerSecond.distance; - if (magnitude < _kMinFlingVelocity) { + if (magnitude < _kMinFlingVelocity || !hasFling) { + if (hasFling) { + mapState.emitMapEvent( + MapEventFlingAnimationNotStarted( + center: mapState.center, + zoom: mapState.zoom, + source: eventSource, + ), + ); + } + return; } var direction = details.velocity.pixelsPerSecond / magnitude; - var distance = (Offset.zero & context.size).shortestSide; - - // correct fling direction with rotation - var v = Matrix4.rotationZ(-degToRadian(mapState.rotation)) * - Vector4(direction.dx, direction.dy, 0, 0); - direction = Offset(v.x, v.y); + var distance = + (Offset.zero & Size(mapState.originalSize.x, mapState.originalSize.y)) + .shortestSide; + var _flingOffset = _focalStartLocal - _lastFocalLocal; _flingAnimation = Tween( begin: _flingOffset, end: _flingOffset - direction * distance, - ).animate(_controller); + ).animate(_flingController); - _controller + _flingController ..value = 0.0 ..fling(velocity: magnitude / 1000.0); } void handleTap(TapPosition position) { - if (options.onTap == null) { - return; - } + closeFlingAnimationController(MapEventSource.tap); + closeDoubleTapController(MapEventSource.tap); + final latlng = _offsetToCrs(position.relative); - // emit the event - options.onTap(latlng); + if (options.onTap != null) { + // emit the event + options.onTap(latlng); + } + + mapState.emitMapEvent( + MapEventTap( + tapPosition: latlng, + center: mapState.center, + zoom: mapState.zoom, + source: MapEventSource.tap, + ), + ); } void handleLongPress(TapPosition position) { _resetDoubleTapHold(); - if (options.onLongPress == null) { - return; - } + closeFlingAnimationController(MapEventSource.longPress); + closeDoubleTapController(MapEventSource.longPress); + final latlng = _offsetToCrs(position.relative); - // emit the event - options.onLongPress(latlng); + if (options.onLongPress != null) { + // emit the event + options.onLongPress(latlng); + } + + mapState.emitMapEvent( + MapEventLongPress( + tapPosition: latlng, + center: mapState.center, + zoom: mapState.zoom, + source: MapEventSource.longPress, + ), + ); } LatLng _offsetToCrs(Offset offset) { - // Get the widget's offset - var renderObject = context.findRenderObject() as RenderBox; - var width = renderObject.size.width; - var height = renderObject.size.height; - - // convert the point to global coordinates - var localPoint = _offsetToPoint(offset); - var localPointCenterDistance = - CustomPoint((width / 2) - localPoint.x, (height / 2) - localPoint.y); - var mapCenter = map.project(map.center); - var point = mapCenter - localPointCenterDistance; - return map.unproject(point); + final focalStartPt = mapState.project(mapState.center, mapState.zoom); + final point = (_offsetToPoint(offset) - (mapState.originalSize / 2.0)) + .rotate(mapState.rotationRad); + + var newCenterPt = focalStartPt + point; + return mapState.unproject(newCenterPt, mapState.zoom); } void handleDoubleTap(TapPosition tapPosition) { _resetDoubleTapHold(); - + if (!options.allowPanning) { return; } - - final centerPos = _pointToOffset(map.size) / 2.0; - final newZoom = _getZoomForScale(map.zoom, 2.0); - final focalDelta = _getDoubleTapFocalDelta( - centerPos, tapPosition.relative, newZoom - map.zoom); - final newCenter = _offsetToCrs(centerPos + focalDelta); - _startDoubleTapAnimation(newZoom, newCenter); + + + closeFlingAnimationController(MapEventSource.doubleTap); + closeDoubleTapController(MapEventSource.doubleTap); + + if (InteractiveFlag.hasFlag( + options.interactiveFlags, InteractiveFlag.doubleTapZoom)) { + final centerPos = _pointToOffset(mapState.originalSize) / 2.0; + final newZoom = _getZoomForScale(mapState.zoom, 2.0); + final focalDelta = _getDoubleTapFocalDelta( + centerPos, tapPosition.relative, newZoom - mapState.zoom); + final newCenter = _offsetToCrs(centerPos + focalDelta); + _startDoubleTapAnimation(newZoom, newCenter); + } } Offset _getDoubleTapFocalDelta( @@ -180,25 +582,41 @@ abstract class MapGestureMixin extends State } void _startDoubleTapAnimation(double newZoom, LatLng newCenter) { - _doubleTapZoomAnimation = Tween(begin: map.zoom, end: newZoom) + _doubleTapZoomAnimation = Tween(begin: mapState.zoom, end: newZoom) .chain(CurveTween(curve: Curves.fastOutSlowIn)) .animate(_doubleTapController); - _doubleTapCenterAnimation = LatLngTween(begin: map.center, end: newCenter) - .chain(CurveTween(curve: Curves.fastOutSlowIn)) - .animate(_doubleTapController); - _doubleTapController - ..value = 0.0 - ..forward(); + _doubleTapCenterAnimation = + LatLngTween(begin: mapState.center, end: newCenter) + .chain(CurveTween(curve: Curves.fastOutSlowIn)) + .animate(_doubleTapController); + _doubleTapController.forward(from: 0.0); } - void _handleDoubleTapZoomAnimation() { - setState(() { - map.move( - _doubleTapCenterAnimation.value, - _doubleTapZoomAnimation.value, - hasGesture: true, + void _doubleTapZoomStatusListener(AnimationStatus status) { + if (status == AnimationStatus.forward) { + mapState.emitMapEvent( + MapEventDoubleTapZoomStart( + center: mapState.center, + zoom: mapState.zoom, + source: MapEventSource.doubleTapZoomAnimationController), + ); + } else if (status == AnimationStatus.completed) { + 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) { @@ -213,17 +631,24 @@ abstract class MapGestureMixin extends State void _handleDoubleTapHold(ScaleUpdateDetails details) { _doubleTapHoldMaxDelay?.cancel(); - setState(() { - final zoom = map.zoom; - final focalOffset = _offsetToPoint(details.localFocalPoint); - final verticalOffset = _pointToOffset(_focalStartLocal - focalOffset).dy; + var flags = options.interactiveFlags; + // TODO: is this pinchZoom? never seen this fired + 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)); - map.move(map.center, actualZoom, hasGesture: true); - }); + mapState.move( + mapState.center, + actualZoom, + hasGesture: true, + source: MapEventSource.doubleTapHold, + ); + } } void _resetDoubleTapHold() { @@ -231,12 +656,39 @@ abstract class MapGestureMixin extends State _tapUpCounter = 0; } + void _flingAnimationStatusListener(AnimationStatus status) { + if (status == AnimationStatus.completed) { + _flingAnimationStarted = false; + mapState.emitMapEvent( + MapEventFlingAnimationEnd( + center: mapState.center, + zoom: mapState.zoom, + source: MapEventSource.flingAnimationController), + ); + } + } + void _handleFlingAnimation() { - _flingOffset = _flingAnimation.value; - var newCenterPoint = map.project(_mapCenterStart) + - CustomPoint(_flingOffset.dx, _flingOffset.dy); - var newCenter = map.unproject(newCenterPoint); - map.move(newCenter, map.zoom, hasGesture: true); + if (!_flingAnimationStarted) { + _flingAnimationStarted = true; + mapState.emitMapEvent( + MapEventFlingAnimationStart( + center: mapState.center, + zoom: mapState.zoom, + source: MapEventSource.flingAnimationController), + ); + } + + var newCenterPoint = mapState.project(_mapCenterStart) + + _offsetToPoint(_flingAnimation.value).rotate(mapState.rotationRad); + var newCenter = mapState.unproject(newCenterPoint); + + mapState.move( + newCenter, + mapState.zoom, + hasGesture: true, + source: MapEventSource.flingAnimationController, + ); } CustomPoint _offsetToPoint(Offset offset) { @@ -248,14 +700,28 @@ abstract class MapGestureMixin extends State } double _getZoomForScale(double startZoom, double scale) { - var resultZoom = startZoom + math.log(scale) / math.ln2; + var 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 map.fitZoomToBounds(resultZoom); + return offset; } @override void dispose() { - _controller.dispose(); + _flingController.dispose(); _doubleTapController.dispose(); super.dispose(); } diff --git a/lib/src/gestures/interactive_flag.dart b/lib/src/gestures/interactive_flag.dart new file mode 100644 index 000000000..b581502c7 --- /dev/null +++ b/lib/src/gestures/interactive_flag.dart @@ -0,0 +1,36 @@ +/// Use [InteractiveFlag] to disable / enable certain events +/// Use [InteractiveFlag.all] to enable all events, use [InteractiveFlag.none] to 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] +class InteractiveFlag { + static const int all = + drag | flingAnimation | pinchMove | pinchZoom | doubleTapZoom | rotate; + static const int none = 0; + + // enable move with one finger + static const int drag = 1 << 0; + + // enable fling animation when drag or pinchMove have enough Fling Velocity + static const int flingAnimation = 1 << 1; + + // enable move with two or more fingers + static const int pinchMove = 1 << 2; + + // enable pinch zoom + static const int pinchZoom = 1 << 3; + + // enable double tap zoom animation + static const int doubleTapZoom = 1 << 4; + + // enable map rotate + static const int rotate = 1 << 5; + + /// Returns `true` if [leftFlags] has at least one member in [rightFlags] (intersection) + /// for example [leftFlags]= [InteractiveFlag.drag] | [InteractiveFlag.rotate] and [rightFlags]= [InteractiveFlag.rotate] | [InteractiveFlag.flingAnimation] + /// returns true because both have [InteractiveFlag.rotate] flag + static bool hasFlag(int leftFlags, int rightFlags) { + return leftFlags & rightFlags != 0; + } +} diff --git a/lib/src/gestures/map_events.dart b/lib/src/gestures/map_events.dart new file mode 100644 index 000000000..3ab472844 --- /dev/null +++ b/lib/src/gestures/map_events.dart @@ -0,0 +1,180 @@ +import 'package:latlong/latlong.dart'; + +enum MapEventSource { + mapController, + tap, + longPress, + doubleTap, + doubleTapHold, + dragStart, + onDrag, + dragEnd, + multiFingerGestureStart, + onMultiFinger, + multiFingerEnd, + flingAnimationController, + doubleTapZoomAnimationController, + interactiveFlagsChanged, +} + +abstract class MapEvent { + // who / what issued the event + final MapEventSource source; + // current center when event is emitted + final LatLng center; + // current zoom when event is emitted + final double zoom; + + MapEvent({this.source, this.center, this.zoom}); +} + +abstract class MapEventWithMove extends MapEvent { + final LatLng targetCenter; + final double targetZoom; + + MapEventWithMove({ + this.targetCenter, + this.targetZoom, + MapEventSource source, + LatLng center, + double zoom, + }) : super(source: source, center: center, zoom: zoom); +} + +class MapEventTap extends MapEvent { + final LatLng tapPosition; + + MapEventTap({ + this.tapPosition, + MapEventSource source, + LatLng center, + double zoom, + }) : super(source: source, center: center, zoom: zoom); +} + +class MapEventLongPress extends MapEvent { + final LatLng tapPosition; + + MapEventLongPress({ + this.tapPosition, + MapEventSource source, + LatLng center, + double zoom, + }) : super(source: source, center: center, zoom: zoom); +} + +class MapEventMove extends MapEventWithMove { + final String id; + + MapEventMove({ + this.id, + LatLng targetCenter, + double targetZoom, + MapEventSource source, + LatLng center, + double zoom, + }) : super( + targetCenter: targetCenter, + targetZoom: targetZoom, + source: source, + center: center, + zoom: zoom, + ); +} + +class MapEventMoveStart extends MapEvent { + MapEventMoveStart({MapEventSource source, LatLng center, double zoom}) + : super(source: source, center: center, zoom: zoom); +} + +class MapEventMoveEnd extends MapEvent { + MapEventMoveEnd({MapEventSource source, LatLng center, double zoom}) + : super(source: source, center: center, zoom: zoom); +} + +class MapEventFlingAnimation extends MapEventWithMove { + MapEventFlingAnimation({ + LatLng targetCenter, + double targetZoom, + MapEventSource source, + LatLng center, + double zoom, + }) : super( + targetCenter: targetCenter, + targetZoom: targetZoom, + source: source, + center: center, + zoom: zoom, + ); +} + +/// Emits when InteractiveFlags contains fling and there wasn't enough velocity +/// to start fling animation +class MapEventFlingAnimationNotStarted extends MapEvent { + MapEventFlingAnimationNotStarted( + {MapEventSource source, LatLng center, double zoom}) + : super(source: source, center: center, zoom: zoom); +} + +class MapEventFlingAnimationStart extends MapEvent { + MapEventFlingAnimationStart( + {MapEventSource source, LatLng center, double zoom}) + : super(source: source, center: center, zoom: zoom); +} + +class MapEventFlingAnimationEnd extends MapEvent { + MapEventFlingAnimationEnd({MapEventSource source, LatLng center, double zoom}) + : super(source: source, center: center, zoom: zoom); +} + +class MapEventDoubleTapZoom extends MapEventWithMove { + MapEventDoubleTapZoom({ + LatLng targetCenter, + double targetZoom, + MapEventSource source, + LatLng center, + double zoom, + }) : super( + targetCenter: targetCenter, + targetZoom: targetZoom, + source: source, + center: center, + zoom: zoom, + ); +} + +class MapEventDoubleTapZoomStart extends MapEvent { + MapEventDoubleTapZoomStart( + {MapEventSource source, LatLng center, double zoom}) + : super(source: source, center: center, zoom: zoom); +} + +class MapEventDoubleTapZoomEnd extends MapEvent { + MapEventDoubleTapZoomEnd({MapEventSource source, LatLng center, double zoom}) + : super(source: source, center: center, zoom: zoom); +} + +class MapEventRotate extends MapEvent { + final String id; + final double currentRotation; + final double targetRotation; + + MapEventRotate({ + this.id, + this.currentRotation, + this.targetRotation, + MapEventSource source, + LatLng center, + double zoom, + }) : super(source: source, center: center, zoom: zoom); +} + +class MapEventRotateStart extends MapEvent { + MapEventRotateStart({MapEventSource source, LatLng center, double zoom}) + : super(source: source, center: center, zoom: zoom); +} + +class MapEventRotateEnd extends MapEvent { + MapEventRotateEnd({MapEventSource source, LatLng center, double zoom}) + : super(source: source, center: center, zoom: zoom); +} diff --git a/lib/src/gestures/multi_finger_gesture.dart b/lib/src/gestures/multi_finger_gesture.dart new file mode 100644 index 000000000..3a7137245 --- /dev/null +++ b/lib/src/gestures/multi_finger_gesture.dart @@ -0,0 +1,26 @@ +/// Use [MultiFingerGesture] to disable / enable certain gestures +/// Use [MultiFingerGesture.all] to enable all gestures, use [MultiFingerGesture.none] to disable all gestures +/// +/// If you want mix gestures for example rotate and pinchZoom gestures then you have two options +/// A.) add you own flags: [MultiFingerGesture.rotate] | [MultiFingerGesture.pinchZoom] +/// B.) remove unnecessary flags from all: [MultiFingerGesture.all] & ~[MultiFingerGesture.pinchMove] +class MultiFingerGesture { + static const int all = pinchMove | pinchZoom | rotate; + static const int none = 0; + + // enable move with two or more fingers + static const int pinchMove = 1 << 0; + + // enable pinch zoom + static const int pinchZoom = 1 << 1; + + // enable map rotate + static const int rotate = 1 << 2; + + /// Returns `true` if [leftFlags] has at least one member in [rightFlags] (intersection) + /// for example [leftFlags]= [MultiFingerGesture.pinchMove] | [MultiFingerGesture.rotate] and [rightFlags]= [MultiFingerGesture.rotate] + /// returns true because both have [MultiFingerGesture.rotate] flag + static bool hasFlag(int leftFlags, int rightFlags) { + return leftFlags & rightFlags != 0; + } +} diff --git a/lib/src/layer/circle_layer.dart b/lib/src/layer/circle_layer.dart index 23b9b3474..e5c227253 100644 --- a/lib/src/layer/circle_layer.dart +++ b/lib/src/layer/circle_layer.dart @@ -10,7 +10,7 @@ class CircleLayerOptions extends LayerOptions { CircleLayerOptions({ Key key, this.circles = const [], - rebuild, + Stream rebuild, }) : super(key: key, rebuild: rebuild); } @@ -36,7 +36,7 @@ class CircleMarker { class CircleLayerWidget extends StatelessWidget { final CircleLayerOptions options; - CircleLayerWidget({@required this.options}) : super(key: options.key); + CircleLayerWidget({Key key, @required this.options}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/src/layer/group_layer.dart b/lib/src/layer/group_layer.dart index d06c60dd6..91cbe36bc 100644 --- a/lib/src/layer/group_layer.dart +++ b/lib/src/layer/group_layer.dart @@ -9,14 +9,14 @@ class GroupLayerOptions extends LayerOptions { GroupLayerOptions({ Key key, this.group, - rebuild, + Stream rebuild, }) : super(key: key, rebuild: rebuild); } class GroupLayerWidget extends StatelessWidget { final GroupLayerOptions options; - GroupLayerWidget({@required this.options}) : super(key: options.key); + GroupLayerWidget({Key key, @required this.options}) : super(key: key); @override Widget build(BuildContext context) { @@ -34,15 +34,6 @@ class GroupLayer extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - // TODO unused BoxContraints should remove? - builder: (BuildContext context, BoxConstraints bc) { - return _build(context); - }, - ); - } - - Widget _build(BuildContext context) { return StreamBuilder( stream: stream, builder: (BuildContext context, _) { diff --git a/lib/src/layer/marker_layer.dart b/lib/src/layer/marker_layer.dart index 748fb1fed..c23978fab 100644 --- a/lib/src/layer/marker_layer.dart +++ b/lib/src/layer/marker_layer.dart @@ -9,7 +9,7 @@ class MarkerLayerOptions extends LayerOptions { MarkerLayerOptions({ Key key, this.markers = const [], - rebuild, + Stream rebuild, }) : super(key: key, rebuild: rebuild); } @@ -93,7 +93,7 @@ class Marker { class MarkerLayerWidget extends StatelessWidget { final MarkerLayerOptions options; - MarkerLayerWidget({@required this.options}) : super(key: options.key); + MarkerLayerWidget({Key key, @required this.options}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/src/layer/overlay_image_layer.dart b/lib/src/layer/overlay_image_layer.dart index 391127005..a753c6aab 100644 --- a/lib/src/layer/overlay_image_layer.dart +++ b/lib/src/layer/overlay_image_layer.dart @@ -11,7 +11,7 @@ class OverlayImageLayerOptions extends LayerOptions { OverlayImageLayerOptions({ Key key, this.overlayImages = const [], - rebuild, + Stream rebuild, }) : super(key: key, rebuild: rebuild); } @@ -32,7 +32,7 @@ class OverlayImage { class OverlayImageLayerWidget extends StatelessWidget { final OverlayImageLayerOptions options; - OverlayImageLayerWidget({@required this.options}) : super(key: options.key); + OverlayImageLayerWidget({Key key, @required this.options}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/src/layer/polygon_layer.dart b/lib/src/layer/polygon_layer.dart index e0ab0eca9..40739677e 100644 --- a/lib/src/layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer.dart @@ -15,7 +15,7 @@ class PolygonLayerOptions extends LayerOptions { Key key, this.polygons = const [], this.polygonCulling = false, - rebuild, + Stream rebuild, }) : super(key: key, rebuild: rebuild) { if (polygonCulling) { for (var polygon in polygons) { @@ -52,7 +52,7 @@ class Polygon { class PolygonLayerWidget extends StatelessWidget { final PolygonLayerOptions options; - PolygonLayerWidget({@required this.options}) : super(key: options.key); + PolygonLayerWidget({Key key, @required this.options}) : super(key: key); @override Widget build(BuildContext context) { @@ -73,7 +73,6 @@ class PolygonLayer extends StatelessWidget { Widget build(BuildContext context) { return LayoutBuilder( builder: (BuildContext context, BoxConstraints bc) { - // TODO unused BoxContraints should remove? final size = Size(bc.maxWidth, bc.maxHeight); return _build(context, size); }, diff --git a/lib/src/layer/polyline_layer.dart b/lib/src/layer/polyline_layer.dart index b11c2172e..b5d286bae 100644 --- a/lib/src/layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer.dart @@ -14,7 +14,7 @@ class PolylineLayerOptions extends LayerOptions { Key key, this.polylines = const [], this.polylineCulling = false, - rebuild, + Stream rebuild, }) : super(key: key, rebuild: rebuild) { if (polylineCulling) { for (var polyline in polylines) { @@ -50,7 +50,7 @@ class Polyline { class PolylineLayerWidget extends StatelessWidget { final PolylineLayerOptions options; - PolylineLayerWidget({@required this.options}) : super(key: options.key); + PolylineLayerWidget({Key key, @required this.options}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/src/layer/tile_layer.dart b/lib/src/layer/tile_layer.dart index 1783d0d62..b5c837da1 100644 --- a/lib/src/layer/tile_layer.dart +++ b/lib/src/layer/tile_layer.dart @@ -229,8 +229,8 @@ class TileLayerOptions extends LayerOptions { this.overrideTilesWhenUrlChanges = false, this.retinaMode = false, this.errorTileCallback, + Stream rebuild, this.templateFunction = util.template, - rebuild, }) : updateInterval = updateInterval <= 0 ? null : Duration(milliseconds: updateInterval), tileFadeInDuration = tileFadeInDuration <= 0 @@ -345,16 +345,11 @@ class WMSTileLayerOptions { } } -class TileLayerWidget extends StatefulWidget { +class TileLayerWidget extends StatelessWidget { final TileLayerOptions options; - TileLayerWidget({@required this.options}) : super(key: options.key); + TileLayerWidget({Key key, @required this.options}) : super(key: key); - @override - State createState() => _TileLayerWidgetState(); -} - -class _TileLayerWidgetState extends State { @override Widget build(BuildContext context) { final mapState = MapState.of(context); @@ -362,7 +357,7 @@ class _TileLayerWidgetState extends State { return TileLayer( mapState: mapState, stream: mapState.onMoved, - options: widget.options, + options: options, ); } } diff --git a/lib/src/map/flutter_map_state.dart b/lib/src/map/flutter_map_state.dart index 86d4fb011..8496424a8 100644 --- a/lib/src/map/flutter_map_state.dart +++ b/lib/src/map/flutter_map_state.dart @@ -1,51 +1,46 @@ import 'dart:async'; -import 'dart:math'; import 'package:async/async.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/core/point.dart'; import 'package:flutter_map/src/gestures/gestures.dart'; import 'package:flutter_map/src/layer/group_layer.dart'; import 'package:flutter_map/src/layer/overlay_image_layer.dart'; import 'package:flutter_map/src/map/map.dart'; import 'package:flutter_map/src/map/map_state_widget.dart'; -import 'package:latlong/latlong.dart'; import 'package:positioned_tap_detector/positioned_tap_detector.dart'; class FlutterMapState extends MapGestureMixin { - final Key _layerStackKey = GlobalKey(); - final Key _positionedTapDetectorKey = GlobalKey(); final MapControllerImpl mapController; final List> groups = >[]; final _positionedTapController = PositionedTapController(); - double rotation = 0.0; @override - MapOptions get options => widget.options ?? MapOptions(); + MapOptions get options => widget.options; @override MapState mapState; - FlutterMapState(this.mapController); + FlutterMapState(MapController mapController) + : mapController = mapController ?? MapController(); @override void didUpdateWidget(FlutterMap oldWidget) { - mapState.options = options; super.didUpdateWidget(oldWidget); + + mapState.options = options; } @override void initState() { super.initState(); - mapState = MapState(options); - rotation = options.rotation; + mapState = MapState(options, (degree) { + if (mounted) setState(() => {}); + }, mapController.mapEventSink); mapController.state = mapState; - mapController.onRotationChanged = - (degree) => setState(() => rotation = degree); } - void _dispose() { + void _disposeStreamGroups() { for (var group in groups) { group.close(); } @@ -55,7 +50,10 @@ class FlutterMapState extends MapGestureMixin { @override void dispose() { - _dispose(); + _disposeStreamGroups(); + mapState.dispose(); + mapController.dispose(); + super.dispose(); } @@ -69,94 +67,76 @@ class FlutterMapState extends MapGestureMixin { return group.stream; } - static const _rad90 = 90.0 * pi / 180.0; - @override Widget build(BuildContext context) { - _dispose(); + _disposeStreamGroups(); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { - double angle; - double width; - double height; - - // only do the rotation maths if we have a rotation - if (rotation != 0.0) { - angle = degToRadian(rotation); - final rangle90 = sin(_rad90 - angle).abs(); - final sinangle = sin(angle).abs(); - // to make sure that the whole screen is filled with the map after rotation - // we enlarge the drawing area over the available screen size - width = (constraints.maxWidth * rangle90) + - (constraints.maxHeight * sinangle); - height = (constraints.maxHeight * rangle90) + - (constraints.maxWidth * sinangle); - - mapState.size = CustomPoint(width, height); - } else { - mapState.size = - CustomPoint(constraints.maxWidth, constraints.maxHeight); - } - - var layerStack = Stack( - key: _layerStackKey, - children: [ - ...widget.children ?? [], - ...widget.layers.map( - (layer) => _createLayer(layer, widget.options.plugins)) ?? - [], - ], - ); - - Widget mapRoot; - - if (!options.interactive) { - mapRoot = layerStack; - } else { - mapRoot = PositionedTapDetector( - key: _positionedTapDetectorKey, - controller: _positionedTapController, - onTap: handleTap, - onLongPress: handleLongPress, - onDoubleTap: handleDoubleTap, - child: GestureDetector( - onScaleStart: handleScaleStart, - onScaleUpdate: handleScaleUpdate, - onScaleEnd: handleScaleEnd, - onTap: _positionedTapController.onTap, - onLongPress: _positionedTapController.onLongPress, - onTapDown: _positionedTapController.onTapDown, - onTapUp: handleOnTapUp, - child: layerStack, - ), - ); - } - - if (rotation != 0.0) { - // By using an OverflowBox with the enlarged drawing area all the layers - // act as if the area really would be that big. So no changes in any layer - // logic is necessary for the rotation - return MapStateInheritedWidget( - mapState: mapState, - child: ClipRect( - child: Transform.rotate( - angle: angle, - child: OverflowBox( - minWidth: width, - maxWidth: width, - minHeight: height, - maxHeight: height, - child: mapRoot, + mapState.setOriginalSize(constraints.maxWidth, constraints.maxHeight); + var size = mapState.size; + + return MapStateInheritedWidget( + mapState: mapState, + child: Listener( + onPointerDown: savePointer, + onPointerCancel: removePointer, + onPointerUp: removePointer, + child: PositionedTapDetector( + controller: _positionedTapController, + onTap: handleTap, + onLongPress: handleLongPress, + onDoubleTap: handleDoubleTap, + child: GestureDetector( + onScaleStart: handleScaleStart, + onScaleUpdate: handleScaleUpdate, + onScaleEnd: handleScaleEnd, + onTap: _positionedTapController.onTap, + onLongPress: _positionedTapController.onLongPress, + onTapDown: _positionedTapController.onTapDown, + onTapUp: handleOnTapUp, + child: ClipRect( + child: Stack( + children: [ + OverflowBox( + minWidth: size.x, + maxWidth: size.x, + minHeight: size.y, + maxHeight: size.y, + child: Transform.rotate( + angle: mapState.rotationRad, + child: Stack( + children: [ + if (widget.children != null && + widget.children.isNotEmpty) + ...widget.children, + if (widget.layers != null && + widget.layers.isNotEmpty) + ...widget.layers.map( + (layer) => _createLayer(layer, options.plugins), + ) + ], + ), + ), + ), + Stack( + children: [ + if (widget.nonRotatedChildren != null && + widget.nonRotatedChildren.isNotEmpty) + ...widget.nonRotatedChildren, + if (widget.nonRotatedLayers != null && + widget.nonRotatedLayers.isNotEmpty) + ...widget.nonRotatedLayers.map( + (layer) => _createLayer(layer, options.plugins), + ) + ], + ), + ], + ), ), ), ), - ); - } else { - return MapStateInheritedWidget( - mapState: mapState, - child: mapRoot, - ); - } + ), + ); }); } diff --git a/lib/src/map/map.dart b/lib/src/map/map.dart index 7ba9f6e97..35310b3ed 100644 --- a/lib/src/map/map.dart +++ b/lib/src/map/map.dart @@ -11,11 +11,17 @@ import 'package:latlong/latlong.dart'; class MapControllerImpl implements MapController { final Completer _readyCompleter = Completer(); + final StreamController _mapEventSink = StreamController.broadcast(); + StreamSink get mapEventSink => _mapEventSink.sink; MapState _state; @override Future get onReady => _readyCompleter.future; + void dispose() { + _mapEventSink.close(); + } + set state(MapState state) { _state = state; if (!_readyCompleter.isCompleted) { @@ -24,8 +30,16 @@ class MapControllerImpl implements MapController { } @override - void move(LatLng center, double zoom, {bool hasGesture = false}) { - _state.move(center, zoom, hasGesture: hasGesture); + MoveAndRotateResult moveAndRotate(LatLng center, double zoom, double degree, + {String id}) { + return _state.moveAndRotate(center, zoom, degree, + source: MapEventSource.mapController, id: id); + } + + @override + bool move(LatLng center, double zoom, {String id}) { + return _state.move(center, zoom, + id: id, source: MapEventSource.mapController); } @override @@ -50,27 +64,36 @@ class MapControllerImpl implements MapController { double get zoom => _state.zoom; @override - void rotate(double degree) { - _state.rotation = degree; - if (onRotationChanged != null) onRotationChanged(degree); - } + double get rotation => _state.rotation; @override - ValueChanged onRotationChanged; + bool rotate(double degree, {String id}) { + return _state.rotate(degree, id: id, source: MapEventSource.mapController); + } @override - Stream get position => _state._positionSink.stream; + Stream get mapEventStream => _mapEventSink.stream; } class MapState { MapOptions options; + final ValueChanged onRotationChanged; final StreamController _onMoveSink; - final StreamController _positionSink; + final StreamSink _mapEventSink; double _zoom; - double rotation; + double _rotation; + double _rotationRad; double get zoom => _zoom; + double get rotation => _rotation; + + set rotation(double rotation) { + _rotation = rotation; + _rotationRad = degToRadian(rotation); + } + + double get rotationRad => _rotationRad; LatLng _lastCenter; LatLngBounds _lastBounds; @@ -78,24 +101,60 @@ class MapState { CustomPoint _pixelOrigin; bool _initialized = false; - MapState(this.options) - : rotation = options.rotation, + MapState(this.options, this.onRotationChanged, this._mapEventSink) + : _rotation = options.rotation, + _rotationRad = degToRadian(options.rotation), _zoom = options.zoom, - _onMoveSink = StreamController.broadcast(), - _positionSink = StreamController.broadcast(); - - CustomPoint _size; + _onMoveSink = StreamController.broadcast(); Stream get onMoved => _onMoveSink.stream; + // Original size of the map where rotation isn't calculated + CustomPoint _originalSize; + + CustomPoint get originalSize => _originalSize; + + void setOriginalSize(double width, double height) { + final isCurrSizeNull = _originalSize == null; + if (isCurrSizeNull || + _originalSize.x != width || + _originalSize.y != height) { + _originalSize = CustomPoint(width, height); + + _updateSizeByOriginalSizeAndRotation(); + + // rebuild layers if screen size has been changed + if (!isCurrSizeNull) { + _onMoveSink.add(null); + } + } + } + + // Extended size of the map where rotation is calculated + CustomPoint _size; + CustomPoint get size => _size; - set size(CustomPoint s) { - _size = s; + void _updateSizeByOriginalSizeAndRotation() { + final originalWidth = _originalSize.x; + final originalHeight = _originalSize.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); + } + if (!_initialized) { _init(); _initialized = true; } + _pixelOrigin = getNewPixelOrigin(_lastCenter); } @@ -113,43 +172,153 @@ class MapState { } } + void _handleMoveEmit(LatLng targetCenter, double targetZoom, hasGesture, + MapEventSource source, String id) { + if (source == MapEventSource.flingAnimationController) { + emitMapEvent( + MapEventFlingAnimation( + center: _lastCenter, + zoom: _zoom, + targetCenter: targetCenter, + targetZoom: targetZoom, + source: source, + ), + ); + } else if (source == MapEventSource.doubleTapZoomAnimationController) { + emitMapEvent( + MapEventDoubleTapZoom( + center: _lastCenter, + zoom: _zoom, + targetCenter: targetCenter, + targetZoom: targetZoom, + source: source, + ), + ); + } else if (source == MapEventSource.onDrag || + source == MapEventSource.onMultiFinger) { + emitMapEvent( + MapEventMove( + center: _lastCenter, + zoom: _zoom, + targetCenter: targetCenter, + targetZoom: targetZoom, + source: source, + ), + ); + } else if (source == MapEventSource.mapController) { + emitMapEvent( + MapEventMove( + id: id, + center: _lastCenter, + zoom: _zoom, + targetCenter: targetCenter, + targetZoom: targetZoom, + source: source, + ), + ); + } + } + + void emitMapEvent(MapEvent event) { + _mapEventSink.add(event); + } + void dispose() { _onMoveSink.close(); + _mapEventSink.close(); } - void forceRebuild() { - _onMoveSink?.add(null); + void rebuildLayers() { + _onMoveSink.add(null); } - void move(LatLng center, double zoom, {hasGesture = false}) { + bool rotate( + double degree, { + hasGesture = false, + callOnMoveSink = true, + MapEventSource source, + String id, + }) { + if (degree != _rotation) { + var oldRotation = _rotation; + rotation = degree; + _updateSizeByOriginalSizeAndRotation(); + + onRotationChanged(_rotation); + + emitMapEvent( + MapEventRotate( + id: id, + currentRotation: oldRotation, + targetRotation: _rotation, + center: _lastCenter, + zoom: _zoom, + source: source, + ), + ); + + if (callOnMoveSink) { + _onMoveSink.add(null); + } + + return true; + } + + return false; + } + + MoveAndRotateResult moveAndRotate(LatLng center, double zoom, double degree, + {MapEventSource source, String id}) { + final moveSucc = + move(center, zoom, id: id, source: source, callOnMoveSink: false); + final rotateSucc = + rotate(degree, id: id, source: source, callOnMoveSink: false); + + if (moveSucc || rotateSucc) { + _onMoveSink.add(null); + } + + return MoveAndRotateResult(moveSucc, rotateSucc); + } + + bool move(LatLng center, double zoom, + {hasGesture = false, + callOnMoveSink = true, + MapEventSource source, + String id}) { zoom = fitZoomToBounds(zoom); final mapMoved = center != _lastCenter || zoom != _zoom; if (_lastCenter != null && (!mapMoved || !bounds.isValid)) { - return; + return false; } if (options.isOutOfBounds(center)) { if (!options.slideOnBoundaries) { - return; + return false; } center = options.containPoint(center, _lastCenter ?? center); } - var mapPosition = MapPosition( - center: center, bounds: bounds, zoom: zoom, hasGesture: hasGesture); + _handleMoveEmit(center, zoom, hasGesture, source, id); _zoom = zoom; _lastCenter = center; _lastPixelBounds = getPixelBounds(_zoom); _lastBounds = _calculateBounds(); _pixelOrigin = getNewPixelOrigin(center); - _onMoveSink.add(null); - _positionSink.add(mapPosition); + if (callOnMoveSink) { + _onMoveSink.add(null); + } if (options.onPositionChanged != null) { + var mapPosition = MapPosition( + center: center, bounds: bounds, zoom: zoom, hasGesture: hasGesture); + options.onPositionChanged(mapPosition, hasGesture); } + + return true; } double fitZoomToBounds(double zoom) { diff --git a/lib/src/map/map_state_widget.dart b/lib/src/map/map_state_widget.dart index 7e593b661..9e97d05ea 100644 --- a/lib/src/map/map_state_widget.dart +++ b/lib/src/map/map_state_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; + import 'map.dart'; class MapStateInheritedWidget extends InheritedWidget { @@ -12,10 +13,10 @@ class MapStateInheritedWidget extends InheritedWidget { @override bool updateShouldNotify(MapStateInheritedWidget oldWidget) { - return oldWidget.mapState.zoom == mapState.zoom && - oldWidget.mapState.center == mapState.center && - oldWidget.mapState.bounds == mapState.bounds && - oldWidget.mapState.rotation == mapState.rotation; + // mapState will be the same because FlutterMapState create MapState object just once + // and pass the same instance to the old / new MapStateInheritedWidget + // Moreover MapStateInheritedWidget child isn't cached so all of it's content will be updated no matter if we return here with false + return true; } static MapStateInheritedWidget of(BuildContext context) {