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; } }