diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..06abe053f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-minor" + - "version-update:semver-patch" \ No newline at end of file diff --git a/codecov.yml b/codecov.yml index 913a895a9..0ed22dfae 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,11 +1,18 @@ codecov: require_ci_to_pass: true +coverage: + status: + project: + default: + # the required coverage value + target: 0% + # the leniency in hitting the target + threshold: 100% + # completely disable coverage warnings and the status check for changes + patch: off + ignore: - "**/*.g.dart" - "example" - - "benchmark" - -comment: - # this posts no PR comment if there are no coverage changes - require_changes: true \ No newline at end of file + - "benchmark" \ No newline at end of file diff --git a/example/lib/pages/polyline.dart b/example/lib/pages/polyline.dart index 8b08aaf81..92dbc9ff5 100644 --- a/example/lib/pages/polyline.dart +++ b/example/lib/pages/polyline.dart @@ -1,7 +1,6 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/misc/tile_providers.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; @@ -15,195 +14,212 @@ class PolylinePage extends StatefulWidget { } class _PolylinePageState extends State { - final randomWalk = [const LatLng(44.861294, 13.845086)]; - double simplificationTolerance = 1; - bool useHighQualitySimplification = false; + final PolylineHitNotifier hitNotifier = ValueNotifier(null); - @override - void initState() { - super.initState(); + final polylines = { + Polyline( + points: [ + const LatLng(51.5, -0.09), + const LatLng(53.3498, -6.2603), + const LatLng(48.8566, 2.3522), + ], + strokeWidth: 8, + color: const Color(0xFF60399E), + ): ( + title: 'Elizabeth Line', + subtitle: 'Nothing really special here...', + ), + Polyline( + points: [ + const LatLng(48.5, -3.09), + const LatLng(47.3498, -9.2603), + const LatLng(43.8566, -1.3522), + ], + strokeWidth: 16000, + color: Colors.pink, + useStrokeWidthInMeter: true, + ): ( + title: 'Pink Line', + subtitle: 'Fixed radius in meters instead of pixels', + ), + Polyline( + points: [ + const LatLng(55.5, -0.09), + const LatLng(54.3498, -6.2603), + const LatLng(52.8566, 2.3522), + ], + strokeWidth: 4, + gradientColors: [ + const Color(0xffE40203), + const Color(0xffFEED00), + const Color(0xff007E2D), + ], + ): ( + title: 'Traffic Light Line', + subtitle: 'Fancy gradient instead of a solid color', + ), + Polyline( + points: [ + const LatLng(50.5, -0.09), + const LatLng(51.3498, 6.2603), + const LatLng(53.8566, 2.3522), + ], + strokeWidth: 20, + color: Colors.blue.withOpacity(0.6), + borderStrokeWidth: 20, + borderColor: Colors.red.withOpacity(0.4), + ): ( + title: 'BlueRed Line', + subtitle: 'Solid translucent color fill, with different color outline', + ), + Polyline( + points: [ + const LatLng(50.2, -0.08), + const LatLng(51.2498, -10.2603), + const LatLng(54.8566, -9.3522), + ], + strokeWidth: 20, + color: Colors.black.withOpacity(0.2), + borderStrokeWidth: 20, + borderColor: Colors.white30, + ): ( + title: 'BlackWhite Line', + subtitle: 'Solid translucent color fill, with different color outline', + ), + Polyline( + points: [ + const LatLng(49.1, -0.06), + const LatLng(52.15, -1.4), + const LatLng(55.5, 0.8), + ], + strokeWidth: 10, + color: Colors.yellow, + borderStrokeWidth: 10, + borderColor: Colors.blue.withOpacity(0.5), + ): ( + title: 'YellowBlue Line', + subtitle: 'Solid translucent color fill, with different color outline', + ), + }; - final random = Random(1234); - for (int i = 1; i < 100000; i++) { - final lat = (random.nextDouble() - 0.5) * 0.001; - final lon = (random.nextDouble() - 0.6) * 0.001; - randomWalk.add(LatLng( - randomWalk[i - 1].latitude + lat, randomWalk[i - 1].longitude + lon)); - } - } + List? hoverLines; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Polylines')), drawer: const MenuDrawer(PolylinePage.route), - body: Stack( + body: FlutterMap( + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, + ), children: [ - FlutterMap( - options: const MapOptions( - initialCenter: LatLng(51.5, -0.09), - initialZoom: 5, - ), - children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'dev.fleaflet.flutter_map.example', + openStreetMapTileLayer, + MouseRegion( + hitTestBehavior: HitTestBehavior.deferToChild, + cursor: SystemMouseCursors.click, + onHover: (_) { + if (hitNotifier.value == null) return; + + final lines = hitNotifier.value!.lines + .where((e) => polylines.containsKey(e)) + .map( + (e) => Polyline( + points: e.points, + strokeWidth: e.strokeWidth + e.borderStrokeWidth, + color: Colors.transparent, + borderStrokeWidth: 15, + borderColor: Colors.green, + useStrokeWidthInMeter: e.useStrokeWidthInMeter, + ), + ) + .toList(); + setState(() => hoverLines = lines); + }, + + /// Clear hovered lines when touched lines modal appears + onExit: (_) => setState(() => hoverLines = null), + child: GestureDetector( + onTap: () => _openTouchedLinesModal( + 'Tapped', + hitNotifier.value!.lines, + hitNotifier.value!.point, + ), + onLongPress: () => _openTouchedLinesModal( + 'Long pressed', + hitNotifier.value!.lines, + hitNotifier.value!.point, ), - PolylineLayer( - simplificationTolerance: simplificationTolerance == 0 - ? null - : simplificationTolerance, - simplificationHighQuality: useHighQualitySimplification, - polylines: [ - Polyline( - points: randomWalk, - strokeWidth: 3, - color: Colors.deepOrange, - ), - ], + onSecondaryTap: () => _openTouchedLinesModal( + 'Secondary tapped', + hitNotifier.value!.lines, + hitNotifier.value!.point, ), - PolylineLayer( - simplificationTolerance: null, - polylines: [ - Polyline( - points: [ - const LatLng(50, 0), - const LatLng(50, -10), - const LatLng(47, -12), - const LatLng(45, -10), - const LatLng(45, 0), - ], - strokeWidth: 8, - color: Colors.purple, - ), - Polyline( - points: [ - const LatLng(51.5, -0.09), - const LatLng(53.3498, -6.2603), - const LatLng(48.8566, 2.3522), - ], - strokeWidth: 4, - color: Colors.purple, - ), - Polyline( - points: [ - const LatLng(55.5, -0.09), - const LatLng(54.3498, -6.2603), - const LatLng(52.8566, 2.3522), - ], - strokeWidth: 4, - gradientColors: [ - const Color(0xffE40203), - const Color(0xffFEED00), - const Color(0xff007E2D), - ], - ), - Polyline( - points: [ - const LatLng(50.5, -0.09), - const LatLng(51.3498, 6.2603), - const LatLng(53.8566, 2.3522), - ], - strokeWidth: 20, - color: Colors.blue.withOpacity(0.6), - borderStrokeWidth: 20, - borderColor: Colors.red.withOpacity(0.4), - ), - Polyline( - points: [ - const LatLng(50.2, -0.08), - const LatLng(51.2498, -10.2603), - const LatLng(54.8566, -9.3522), - ], - strokeWidth: 20, - color: Colors.black.withOpacity(0.2), - borderStrokeWidth: 20, - borderColor: Colors.white30, - ), - Polyline( - points: [ - const LatLng(49.1, -0.06), - const LatLng(52.15, -1.4), - const LatLng(55.5, 0.8), - ], - strokeWidth: 10, - color: Colors.yellow, - borderStrokeWidth: 10, - borderColor: Colors.blue.withOpacity(0.5), - ), - Polyline( - points: [ - const LatLng(48.1, -0.03), - const LatLng(50.5, -7.8), - const LatLng(56.5, 0.4), - ], - strokeWidth: 10, - color: Colors.amber, - borderStrokeWidth: 10, - borderColor: Colors.blue.withOpacity(0.5), - ), - ], + child: PolylineLayer( + hitNotifier: hitNotifier, + polylines: polylines.keys.followedBy(hoverLines ?? []).toList(), ), - ], + ), ), - Positioned( - left: 32, - top: 16, - right: 32, - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background, - borderRadius: BorderRadius.circular(24), + ], + ), + ); + } + + void _openTouchedLinesModal( + String eventType, + List tappedLines, + LatLng coords, + ) { + tappedLines.removeWhere((e) => !polylines.containsKey(e)); + + showModalBottomSheet( + context: context, + builder: (context) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tapped Polyline(s)', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + Text( + '$eventType at point: (${coords.latitude.toStringAsFixed(6)}, ${coords.longitude.toStringAsFixed(6)})', + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + final tappedLineData = polylines[tappedLines[index]]!; + return ListTile( + leading: index == 0 + ? const Icon(Icons.vertical_align_top) + : index == tappedLines.length - 1 + ? const Icon(Icons.vertical_align_bottom) + : const SizedBox.shrink(), + title: Text(tappedLineData.title), + subtitle: Text(tappedLineData.subtitle), + dense: true, + ); + }, + itemCount: tappedLines.length, ), - child: Padding( - padding: MediaQuery.sizeOf(context).width >= 500 - ? const EdgeInsets.symmetric(horizontal: 16, vertical: 0) - : const EdgeInsets.only(left: 12, right: 12, top: 8), - child: Column( - children: [ - if (MediaQuery.sizeOf(context).width < 500) - const Text( - 'Simplification Tolerance', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Row( - children: [ - if (MediaQuery.sizeOf(context).width >= 500) - const Text( - 'Simplification Tolerance', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Expanded( - child: Slider( - value: simplificationTolerance, - onChanged: (v) => - setState(() => simplificationTolerance = v), - min: 0, - max: 1.25, - divisions: 125, - label: simplificationTolerance == 0 - ? 'Disabled' - : simplificationTolerance.toStringAsFixed(2), - ), - ), - IconButton.filledTonal( - onPressed: () => setState( - () => useHighQualitySimplification = - !useHighQualitySimplification, - ), - icon: const Icon(Icons.high_quality_outlined), - selectedIcon: const Icon(Icons.high_quality), - isSelected: useHighQualitySimplification, - tooltip: 'Use High Quality Simplification', - ), - ], - ), - ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), ), ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/src/layer/polyline_layer.dart b/lib/src/layer/polyline_layer.dart index ba86fa6f9..bb2254557 100644 --- a/lib/src/layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer.dart @@ -1,12 +1,34 @@ import 'dart:core'; -import 'dart:math'; +import 'dart:math' as math; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/misc/simplify.dart'; import 'package:latlong2/latlong.dart'; +/// Result from polyline hit detection +/// +/// Emmitted by [PolylineLayer.hitNotifier]'s [ValueNotifier] +/// ([PolylineHitNotifier]). +class PolylineHit { + /// All hit [Polyline]s within the corresponding layer + /// + /// Ordered from first-last, visually top-bottom. + final List lines; + + /// Coordinates of the detected hit + /// + /// Note that this may not lie on a [Polyline]. + final LatLng point; + + const PolylineHit._({required this.lines, required this.point}); +} + +/// Typedef used on [PolylineLayer.hitNotifier] +typedef PolylineHitNotifier = ValueNotifier; + class Polyline { final List points; final double strokeWidth; @@ -34,18 +56,38 @@ class Polyline { this.useStrokeWidthInMeter = false, }); - /// Used to batch draw calls to the canvas. + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Polyline && + listEquals(points, other.points) && + strokeWidth == other.strokeWidth && + color == other.color && + borderStrokeWidth == other.borderStrokeWidth && + borderColor == other.borderColor && + listEquals(gradientColors, other.gradientColors) && + listEquals(colorsStop, other.colorsStop) && + isDotted == other.isDotted && + strokeCap == other.strokeCap && + strokeJoin == other.strokeJoin && + useStrokeWidthInMeter == other.useStrokeWidthInMeter); + + /// Used to batch draw calls to the canvas int get renderHashCode => Object.hash( - strokeWidth, - color, - borderStrokeWidth, - borderColor, - gradientColors, - colorsStop, - isDotted, - strokeCap, - strokeJoin, - useStrokeWidthInMeter); + strokeWidth, + color, + borderStrokeWidth, + borderColor, + gradientColors, + colorsStop, + isDotted, + strokeCap, + strokeJoin, + useStrokeWidthInMeter, + ); + + @override + int get hashCode => Object.hash(points, renderHashCode); } @immutable @@ -63,6 +105,35 @@ class PolylineLayer extends StatelessWidget { /// otherwise, points within the radial distance of the threshold value are merged. (Also called radial distance simplification) /// radial distance is faster, but does not preserve the shape of the original line as well as Douglas Peucker final bool simplificationHighQuality; + /// A notifier to notify when a hit is detected over a/multiple [Polyline]s + /// + /// To listen for hits, wrap the layer in a standard hit detector widget, such + /// as [GestureDetector] and/or [MouseRegion] (and set + /// [HitTestBehavior.deferToChild] if necessary). Then use the latest value + /// (via [ValueNotifier.value]) in the detector's callbacks. It is also + /// possible to listen to the notifier directly. + /// + /// Note that a hover event is included as a hit event. Therefore for + /// performance reasons, it may be advantageous to check the new value's + /// equality against the previous value (excluding the [PolylineHit.point], + /// which will always change), and avoid doing any heavy work if they are the + /// same. + /// + /// See online documentation for more detailed usage instructions. See the + /// example project for an example implementation. + /// + /// Will notify with [PolylineHit]s when any [Polyline]s are hit, otherwise + /// will notify with `null`. + final PolylineHitNotifier? hitNotifier; + + /// The minimum radius of the hittable area around each [Polyline] in logical + /// pixels + /// + /// The entire visible area is always hittable, but if the visible area is + /// smaller than this, then this will be the hittable area. + /// + /// Defaults to 10. + final double minimumHitbox; const PolylineLayer({ super.key, @@ -70,6 +141,8 @@ class PolylineLayer extends StatelessWidget { this.polylineCullingMargin = 0, this.simplificationTolerance = 1, this.simplificationHighQuality = false, + this.hitNotifier, + this.minimumHitbox = 10, }); @override @@ -83,14 +156,14 @@ class PolylineLayer extends StatelessWidget { } else { final bounds = mapCamera.visibleBounds; final margin = - polylineCullingMargin! / pow(2, mapCamera.zoom.floorToDouble()); + polylineCullingMargin! / math.pow(2, mapCamera.zoom.floorToDouble()); // The min(-90), max(180), etc.. are used to get around the limits of LatLng // the value cannot be greater or smaller than that final boundsAdjusted = LatLngBounds( - LatLng(max(-90, bounds.southWest.latitude - margin), - max(-180, bounds.southWest.longitude - margin)), - LatLng(min(90, bounds.northEast.latitude + margin), - min(180, bounds.northEast.longitude + margin))); + LatLng(math.max(-90, bounds.southWest.latitude - margin), + math.max(-180, bounds.southWest.longitude - margin)), + LatLng(math.min(90, bounds.northEast.latitude + margin), + math.min(180, bounds.northEast.longitude + margin))); for (final polyline in polylines) { // Gradiant poylines do not render identically and cannot be easily segmented @@ -108,9 +181,9 @@ class PolylineLayer extends StatelessWidget { // segment is visible if (Bounds( - Point(boundsAdjusted.southWest.longitude, + math.Point(boundsAdjusted.southWest.longitude, boundsAdjusted.southWest.latitude), - Point(boundsAdjusted.northEast.longitude, + math.Point(boundsAdjusted.northEast.longitude, boundsAdjusted.northEast.latitude)) .aabbContainsLine( p1.longitude, p1.latitude, p2.longitude, p2.latitude)) { @@ -170,37 +243,47 @@ class PolylineLayer extends StatelessWidget { return MobileLayerTransformer( child: CustomPaint( - painter: PolylinePainter(renderedLines, mapCamera, - simplificationTolerance, simplificationHighQuality), + painter: _PolylinePainter( + polylines: renderedLines, + simplificationHighQuality: simplificationHighQuality, + simplificationTolerance: simplificationTolerance, + camera: mapCamera, + hitNotifier: hitNotifier, + minimumHitbox: minimumHitbox, + ), size: Size(mapCamera.size.x, mapCamera.size.y), isComplex: true, - ), - ); + )); } } -class PolylinePainter extends CustomPainter { +class _PolylinePainter extends CustomPainter { final List polylines; - final MapCamera camera; final LatLngBounds bounds; + final PolylineHitNotifier? hitNotifier; + final double minimumHitbox; final double? simplificationTolerance; final bool simplificationHighQuality; - PolylinePainter(this.polylines, this.camera, this.simplificationTolerance, - this.simplificationHighQuality) - : bounds = camera.visibleBounds; + // Avoids reallocation on every `hitTest`, is cleared every time + final hits = List.empty(growable: true); int get hash => _hash ??= Object.hashAll(polylines); - int? _hash; + _PolylinePainter({required this.polylines, + required this.camera, this.simplificationTolerance, + required this.simplificationHighQuality, required this.hitNotifier, + required this.minimumHitbox}) + : bounds = camera.visibleBounds; + List getOffsets(Offset origin, List points) { final List simplifiedPoints; if (simplificationTolerance != null) { simplifiedPoints = simplify(points, - simplificationTolerance! / pow(2, camera.zoom.floorToDouble()), + simplificationTolerance! / math.pow(2, camera.zoom.floorToDouble()), highestQuality: simplificationHighQuality); } else { simplifiedPoints = points; @@ -216,6 +299,67 @@ class PolylinePainter extends CustomPainter { return Offset(projected.x - origin.dx, projected.y - origin.dy); } + @override + bool? hitTest(Offset position) { + if (hitNotifier == null) return null; + + hits.clear(); + + final origin = + camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; + + for (final p in polylines.reversed) { + // TODO: For efficiency we'd ideally filter by bounding box here. However + // we'd need to compute an extended bounding box that accounts account for + // the stroke width. + // if (!p.boundingBox.contains(touch)) { + // continue; + // } + + final offsets = getOffsets(origin, p.points); + final strokeWidth = p.useStrokeWidthInMeter + ? _metersToStrokeWidth( + origin, + p.points.first, + offsets.first, + p.strokeWidth, + ) + : p.strokeWidth; + final hittableDistance = + math.max(strokeWidth / 2 + p.borderStrokeWidth / 2, minimumHitbox); + + for (int i = 0; i < offsets.length - 1; i++) { + final o1 = offsets[i]; + final o2 = offsets[i + 1]; + + final distance = math.sqrt(_distToSegmentSquared( + position.dx, + position.dy, + o1.dx, + o1.dy, + o2.dx, + o2.dy, + )); + + if (distance < hittableDistance) { + hits.add(p); + break; + } + } + } + + if (hits.isEmpty) { + hitNotifier!.value = null; + return false; + } + + hitNotifier!.value = PolylineHit._( + lines: hits, + point: camera.pointToLatLng(math.Point(position.dx, position.dy)), + ); + return true; + } + @override void paint(Canvas canvas, Size size) { final rect = Offset.zero & size; @@ -274,16 +418,12 @@ class PolylinePainter extends CustomPainter { late final double strokeWidth; if (polyline.useStrokeWidthInMeter) { - final firstPoint = polyline.points.first; - final firstOffset = offsets.first; - final r = const Distance().offset( - firstPoint, + strokeWidth = _metersToStrokeWidth( + origin, + polyline.points.first, + offsets.first, polyline.strokeWidth, - 180, ); - final delta = firstOffset - getOffset(origin, r); - - strokeWidth = delta.distance; } else { strokeWidth = polyline.strokeWidth; } @@ -395,10 +535,48 @@ class PolylinePainter extends CustomPainter { .toList(); } + double _metersToStrokeWidth( + Offset origin, + LatLng p0, + Offset o0, + double strokeWidthInMeters, + ) { + final r = _distance.offset(p0, strokeWidthInMeters, 180); + final delta = o0 - getOffset(origin, r); + return delta.distance; + } + @override - bool shouldRepaint(PolylinePainter oldDelegate) { + bool shouldRepaint(_PolylinePainter oldDelegate) { return oldDelegate.bounds != bounds || oldDelegate.polylines.length != polylines.length || oldDelegate.hash != hash; } } + +double _distanceSq(double x0, double y0, double x1, double y1) { + final dx = x0 - x1; + final dy = y0 - y1; + return dx * dx + dy * dy; +} + +double _distToSegmentSquared( + double px, + double py, + double x0, + double y0, + double x1, + double y1, +) { + final dx = x1 - x0; + final dy = y1 - y0; + final distanceSq = dx * dx + dy * dy; + if (distanceSq == 0) { + return _distanceSq(px, py, x0, y0); + } + + final t = (((px - x0) * dx + (py - y0) * dy) / distanceSq).clamp(0, 1); + return _distanceSq(px, py, x0 + t * dx, y0 + t * dy); +} + +const _distance = Distance(); diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index 5117be15d..a618ae67a 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -17,13 +17,25 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> late final MapInteractiveViewerState _interactiveViewerState; - MapControllerImpl([MapOptions? options]) + Animation? _moveAnimation; + Animation? _zoomAnimation; + Animation? _rotationAnimation; + Animation? _flingAnimation; + late bool _animationHasGesture; + late Offset _animationOffset; + late Point _flingMapCenterStartPoint; + + MapControllerImpl({MapOptions? options, TickerProvider? vsync}) : super( _MapControllerState( options: options, camera: options == null ? null : MapCamera.initialCamera(options), + animationController: + vsync == null ? null : AnimationController(vsync: vsync), ), - ); + ) { + value.animationController?.addListener(_handleAnimation); + } /// Link the viewer state with the controller. This should be done once when /// the FlutterMapInteractiveViewerState is initialized. @@ -50,6 +62,12 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> 'least once before using the MapController.')); } + AnimationController get _animationController { + return value.animationController ?? + (throw Exception('You need to have the FlutterMap widget rendered at ' + 'least once before using the MapController.')); + } + /// This setter should only be called in this class or within tests. Changes /// to the [_MapControllerState] should be done via methods in this class. @visibleForTesting @@ -179,29 +197,27 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> required MapEventSource source, String? id, }) { - if (newRotation != camera.rotation) { - final newCamera = options.cameraConstraint.constrain( - camera.withRotation(newRotation), - ); - if (newCamera == null) return false; + if (newRotation == camera.rotation) return false; - final oldCamera = camera; + final newCamera = options.cameraConstraint.constrain( + camera.withRotation(newRotation), + ); + if (newCamera == null) return false; - // Update camera then emit events and callbacks - value = value.withMapCamera(newCamera); + final oldCamera = camera; - _emitMapEvent( - MapEventRotate( - id: id, - source: source, - oldCamera: oldCamera, - camera: camera, - ), - ); - return true; - } + // Update camera then emit events and callbacks + value = value.withMapCamera(newCamera); - return false; + _emitMapEvent( + MapEventRotate( + id: id, + source: source, + oldCamera: oldCamera, + camera: camera, + ), + ); + return true; } MoveAndRotateResult rotateAroundPointRaw( @@ -340,9 +356,23 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> value = _MapControllerState( options: newOptions, camera: newCamera, + animationController: value.animationController, ); } + set vsync(TickerProvider tickerProvider) { + if (value.animationController == null) { + value = _MapControllerState( + options: value.options, + camera: value.camera, + animationController: AnimationController(vsync: tickerProvider) + ..addListener(_handleAnimation), + ); + } else { + _animationController.resync(tickerProvider); + } + } + /// To be called when a gesture that causes movement starts. void moveStarted(MapEventSource source) { _emitMapEvent( @@ -508,6 +538,161 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> ); } + void moveAndRotateAnimatedRaw( + LatLng newCenter, + double newZoom, + double newRotation, { + required Offset offset, + required Duration duration, + required Curve curve, + required bool hasGesture, + required MapEventSource source, + }) { + if (newRotation == camera.rotation) { + moveAnimatedRaw( + newCenter, + newZoom, + duration: duration, + curve: curve, + hasGesture: hasGesture, + source: source, + ); + return; + } + // cancel all ongoing animation + _animationController.stop(); + _resetAnimations(); + + if (newCenter == camera.center && newZoom == camera.zoom) return; + + // create the new animation + _moveAnimation = LatLngTween(begin: camera.center, end: newCenter) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + _zoomAnimation = Tween(begin: camera.zoom, end: newZoom) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + _rotationAnimation = Tween(begin: camera.rotation, end: newRotation) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + + _animationController.duration = duration; + _animationHasGesture = hasGesture; + _animationOffset = offset; + + // start the animation from its start + _animationController.forward(from: 0); + } + + void rotateAnimatedRaw( + double newRotation, { + required Offset offset, + required Duration duration, + required Curve curve, + required bool hasGesture, + required MapEventSource source, + }) { + // cancel all ongoing animation + _animationController.stop(); + _resetAnimations(); + + if (newRotation == camera.rotation) return; + + // create the new animation + _rotationAnimation = Tween(begin: camera.rotation, end: newRotation) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + + _animationController.duration = duration; + _animationHasGesture = hasGesture; + _animationOffset = offset; + + // start the animation from its start + _animationController.forward(from: 0); + } + + void stopAnimationRaw({bool canceled = true}) { + if (isAnimating) _animationController.stop(canceled: canceled); + } + + bool get isAnimating => _animationController.isAnimating; + + void _resetAnimations() { + _moveAnimation = null; + _rotationAnimation = null; + _zoomAnimation = null; + _flingAnimation = null; + } + + void flingAnimatedRaw({ + required double velocity, + required Offset direction, + required Offset begin, + Offset offset = Offset.zero, + double mass = 1, + double stiffness = 1000, + double ratio = 5, + required bool hasGesture, + }) { + // cancel all ongoing animation + _animationController.stop(); + _resetAnimations(); + + _animationHasGesture = hasGesture; + _animationOffset = offset; + _flingMapCenterStartPoint = camera.project(camera.center); + + final distance = + (Offset.zero & Size(camera.nonRotatedSize.x, camera.nonRotatedSize.y)) + .shortestSide; + + _flingAnimation = Tween( + begin: begin, + end: begin - direction * distance, + ).animate(_animationController); + + _animationController.value = 0; + _animationController.fling( + velocity: velocity, + springDescription: SpringDescription.withDampingRatio( + mass: mass, + stiffness: stiffness, + ratio: ratio, + ), + ); + } + + void moveAnimatedRaw( + LatLng newCenter, + double newZoom, { + Offset offset = Offset.zero, + required Duration duration, + required Curve curve, + required bool hasGesture, + required MapEventSource source, + }) { + // cancel all ongoing animation + _animationController.stop(); + _resetAnimations(); + + if (newCenter == camera.center && newZoom == camera.zoom) return; + + // create the new animation + _moveAnimation = LatLngTween(begin: camera.center, end: newCenter) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + _zoomAnimation = Tween(begin: camera.zoom, end: newZoom) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + + _animationController.duration = duration; + _animationHasGesture = hasGesture; + _animationOffset = offset; + + // start the animation from its start + _animationController.forward(from: 0); + } + void _emitMapEvent(MapEvent event) { if (event.source == MapEventSource.mapController && event is MapEventMove) { _interactiveViewerState.interruptAnimatedMovement(event); @@ -518,9 +703,58 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> _mapEventSink.add(event); } + void _handleAnimation() { + // fling animation + if (_flingAnimation != null) { + final newCenterPoint = _flingMapCenterStartPoint + + _flingAnimation!.value.toPoint().rotate(camera.rotationRad); + moveRaw( + camera.unproject(newCenterPoint), + camera.zoom, + hasGesture: _animationHasGesture, + source: MapEventSource.flingAnimationController, + offset: _animationOffset, + ); + return; + } + + // animated movement + if (_moveAnimation != null) { + if (_rotationAnimation != null) { + moveAndRotateRaw( + _moveAnimation?.value ?? camera.center, + _zoomAnimation?.value ?? camera.zoom, + _rotationAnimation!.value, + hasGesture: _animationHasGesture, + source: MapEventSource.mapController, + offset: _animationOffset, + ); + } else { + moveRaw( + _moveAnimation!.value, + _zoomAnimation?.value ?? camera.zoom, + hasGesture: _animationHasGesture, + source: MapEventSource.mapController, + offset: _animationOffset, + ); + } + return; + } + + // animated rotation + if (_rotationAnimation != null) { + rotateRaw( + _rotationAnimation!.value, + hasGesture: _animationHasGesture, + source: MapEventSource.mapController, + ); + } + } + @override void dispose() { _mapEventStreamController.close(); + value.animationController?.dispose(); super.dispose(); } } @@ -529,14 +763,17 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> class _MapControllerState { final MapCamera? camera; final MapOptions? options; + final AnimationController? animationController; const _MapControllerState({ required this.options, required this.camera, + required this.animationController, }); _MapControllerState withMapCamera(MapCamera camera) => _MapControllerState( options: options, camera: camera, + animationController: animationController, ); } diff --git a/lib/src/map/widget.dart b/lib/src/map/widget.dart index 76d7291fa..958f0cdb9 100644 --- a/lib/src/map/widget.dart +++ b/lib/src/map/widget.dart @@ -51,7 +51,7 @@ class FlutterMap extends StatefulWidget { } class _FlutterMapStateContainer extends State - with AutomaticKeepAliveClientMixin { + with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { bool _initialCameraFitApplied = false; late MapControllerImpl _mapController; @@ -184,9 +184,10 @@ class _FlutterMapStateContainer extends State void _setMapController() { if (_controllerCreatedInternally) { - _mapController = MapControllerImpl(widget.options); + _mapController = MapControllerImpl(options: widget.options, vsync: this); } else { _mapController = widget.mapController! as MapControllerImpl; + _mapController.vsync = this; _mapController.options = widget.options; } }