diff --git a/example/integration_test/shared.dart b/example/integration_test/shared.dart index 0442ea56..c5a15715 100644 --- a/example/integration_test/shared.dart +++ b/example/integration_test/shared.dart @@ -254,6 +254,7 @@ Future getMapViewControllerForTestMapType( void Function(NavigationViewRecenterButtonClickedEvent)? onRecenterButtonClicked, void Function(CameraPosition)? onCameraIdle, + void Function(CameraPosition, bool)? onCameraMoveStarted, }) async { GoogleMapViewController viewController; @@ -277,6 +278,7 @@ Future getMapViewControllerForTestMapType( onPolygonClicked: onPolygonClicked, onPolylineClicked: onPolylineClicked, onCameraIdle: onCameraIdle, + onCameraMoveStarted: onCameraMoveStarted, ); // Instantiate a regular map. break; @@ -303,6 +305,7 @@ Future getMapViewControllerForTestMapType( onPolylineClicked: onPolylineClicked, onRecenterButtonClicked: onRecenterButtonClicked, onCameraIdle: onCameraIdle, + onCameraMoveStarted: onCameraMoveStarted, ); // Instantiate a navigation map. break; } @@ -421,6 +424,7 @@ Future startMapView( void Function(NavigationViewRecenterButtonClickedEvent)? onRecenterButtonClicked, void Function(CameraPosition)? onCameraIdle, + void Function(CameraPosition, bool)? onCameraMoveStarted, }) async { final ControllerCompleter controllerCompleter = ControllerCompleter(); @@ -449,6 +453,7 @@ Future startMapView( onPolylineClicked: onPolylineClicked, onRecenterButtonClicked: onRecenterButtonClicked, onCameraIdle: onCameraIdle, + onCameraMoveStarted: onCameraMoveStarted, ), ); diff --git a/example/integration_test/t05_camera_test.dart b/example/integration_test/t05_camera_test.dart index ff56a1d8..364e20a6 100644 --- a/example/integration_test/t05_camera_test.dart +++ b/example/integration_test/t05_camera_test.dart @@ -31,6 +31,7 @@ void main() { Completer cameraMoveStartedCompleter = Completer(); Completer cameraMoveCompleter = Completer(); Completer cameraIdleCompleter = Completer(); + Completer cameraAnimationOnFinishedCompleter = Completer(); late CameraPosition cameraMoveStartedPosition; late CameraPosition cameraMovePosition; late CameraPosition cameraIdlePosition; @@ -77,10 +78,19 @@ void main() { } /// Reset the camera event completers. - void resetCameraEventCompleters() { + Future resetCameraEventCompleters( + PatrolIntegrationTester $, { + bool waitForEvents = true, + }) async { + // Wait for a while to be sure all possible events are fired before + // resetting the completers. + await $.tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); cameraMoveStartedCompleter = Completer(); cameraMoveCompleter = Completer(); cameraIdleCompleter = Completer(); + cameraAnimationOnFinishedCompleter = Completer(); } double distanceToNorth(double angle) { @@ -112,6 +122,12 @@ void main() { expectedPosition.bearing; } + /// Check the received camera bearing value is greater than or equal to the expected bearing value. + bool checkBearingGreaterThanOrEqualTo(CameraPosition receivedPosition) { + return distanceToNorth(receivedPosition.bearing) <= + expectedPosition.bearing; + } + /// Check that the received camera target value doesn't match the expected camera target value. bool checkCoordinatesDiffer(CameraPosition receivedPosition) { return receivedPosition.target != expectedPosition.target; @@ -218,7 +234,9 @@ void main() { CameraPosition? camera = await waitForValueMatchingPredicate( $, getPosition, - checkTiltGreaterThanOrEqualTo, + (CameraPosition cameraPosition) => + checkTiltGreaterThanOrEqualTo(cameraPosition) && + checkBearingGreaterThanOrEqualTo(cameraPosition), ); expect(camera, isNotNull); @@ -271,17 +289,15 @@ void main() { expectedPosition = const CameraPosition(bearing: 1.0, tilt: 0.1); await controller.followMyLocation(CameraPerspective.topDownNorthUp); - // Wait until the bearing & tilt are less than or equal to the expected bearing & tilt. - // On iOS the random small tilt change caused occasional test failures. - await waitForValueMatchingPredicate( - $, - getPosition, - checkTiltLessThanOrEqualTo, - ); + // Wait until the bearing & tilt are less than or equal to the expected + // bearing & tilt. On iOS the random small tilt change caused occasional + // test failures. camera = await waitForValueMatchingPredicate( $, getPosition, - checkBearingLessThanOrEqualTo, + (CameraPosition cameraPosition) => + checkTiltLessThanOrEqualTo(cameraPosition) && + checkBearingLessThanOrEqualTo(cameraPosition), ); expect(camera, isNotNull); @@ -344,14 +360,17 @@ void main() { oldZoom = camera.zoom; // 5. Test showRouteOverview(). - expectedPosition = const CameraPosition(tilt: 0.1); + expectedPosition = const CameraPosition(tilt: 0.1, bearing: 1.0); await controller.showRouteOverview(); - // Wait until the tilt is less than or equal to the expected tilt. + // Wait until the tilt and bearing are less than + // or equal to the expected values. camera = await waitForValueMatchingPredicate( $, getPosition, - checkTiltLessThanOrEqualTo, + (CameraPosition cameraPosition) => + checkTiltLessThanOrEqualTo(cameraPosition) && + checkBearingLessThanOrEqualTo(cameraPosition), ); // Test that stoppedFollowingMyLocation event has been received and @@ -416,7 +435,7 @@ void main() { oldZoom = camera.zoom; // 7. Test stoppedFollowingMyLocation event. - resetCameraEventCompleters(); + await resetCameraEventCompleters($); // Stop followMyLocation. final CameraUpdate positionUpdate = CameraUpdate.newLatLng( @@ -453,7 +472,6 @@ void main() { // 8. Test cameraMoveStarted, cameraMove and cameraIdle events. await GoogleMapsNavigator.simulator.pauseSimulation(); camera = await controller.getCameraPosition(); - resetCameraEventCompleters(); final LatLng newTarget = LatLng( latitude: camera.target.latitude + 0.5, @@ -466,6 +484,7 @@ void main() { // Move camera and wait for cameraMoveStarted, cameraMove // and cameraIdle events to come in. + await resetCameraEventCompleters($); await controller.moveCamera(cameraUpdate); await waitForCameraEvents($); @@ -539,12 +558,12 @@ void main() { ); // Move camera back to the start. Future moveCameraToStart() async { - resetCameraEventCompleters(); + await resetCameraEventCompleters($); await viewController.moveCamera(start); await cameraIdleCompleter.future.timeout( const Duration(seconds: 10), onTimeout: () { - fail('Future timed out'); + fail('cameraIdleCompleter Future timed out on moveCameraToStart'); }, ); } @@ -554,12 +573,12 @@ void main() { // Create a wrapper for moveCamera() that waits until the move is finished. Future moveCamera(CameraUpdate update) async { - resetCameraEventCompleters(); + await resetCameraEventCompleters($); await viewController.moveCamera(update); await cameraIdleCompleter.future.timeout( const Duration(seconds: 10), onTimeout: () { - fail('Future timed out'); + fail('cameraIdleCompleter Future timed out'); }, ); } @@ -567,26 +586,35 @@ void main() { // Create a wrapper for animateCamera() that waits until move is finished // using cameraIdle event on iOS and onFinished listener on Android. Future animateCamera(CameraUpdate update) async { - resetCameraEventCompleters(); + await resetCameraEventCompleters($); - // Create onFinished callback function that is used on Android - // to test that the callback comes in. + // Create onFinished callback function that is used to test that the + // callback comes in. void onFinished(bool finished) { + cameraAnimationOnFinishedCompleter.complete(); expect(finished, true); } // Animate camera to the set position with reduced duration. await viewController.animateCamera( update, - duration: const Duration(milliseconds: 50), + duration: const Duration(milliseconds: 500), onFinished: onFinished, ); - // Wait until the cameraIdle event comes in. + // Make sure onFinished event is received. + await cameraAnimationOnFinishedCompleter.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + fail('cameraAnimationOnFinishedCompleter Future timed out'); + }, + ); + + // Make sure camera idle event is received. await cameraIdleCompleter.future.timeout( const Duration(seconds: 10), onTimeout: () { - fail('Future timed out'); + fail('cameraIdleCompleter Future timed out'); }, ); } @@ -920,4 +948,244 @@ void main() { }, variant: mapTypeVariants, ); + + patrol('Test camera animation cancellation by drag gesture', ( + PatrolIntegrationTester $, + ) async { + // Long enough duration to ensure animation is running when we cancel it. + const Duration animationDuration = Duration(seconds: 10); + // Max time to wait for cancellation to complete. + const Duration cancellationTimeout = Duration(seconds: 1); + // Max time to wait for camera to become idle. + const Duration cameraIdleTimeout = Duration(seconds: 9); + // Max time to wait for camera animation to start. + const Duration cameraMoveStartTimeout = Duration(seconds: 1); + + const LatLng target = LatLng( + latitude: startLocationLat + 1, + longitude: startLocationLng + 1, + ); + final CameraUpdate startCameraUpdate = CameraUpdate.newLatLngZoom( + LatLng(latitude: startLocationLat, longitude: startLocationLng), + 10, + ); + final CameraUpdate cancelCameraUpdate = CameraUpdate.newLatLngZoom( + target, + 15, + ); + + /// Initialize view with the event listener functions. + final GoogleMapViewController viewController = + await getMapViewControllerForTestMapType( + $, + testMapType: mapTypeVariants.currentValue!, + initializeNavigation: false, + simulateLocation: false, + onCameraIdle: onCameraIdle, + onCameraMoveStarted: onCameraMoveStarted, + ); + + await resetCameraEventCompleters($); + await viewController.moveCamera(startCameraUpdate); + await cameraIdleCompleter.future.timeout( + cameraIdleTimeout, + onTimeout: () { + fail('cameraIdleCompleter Future timed out'); + }, + ); + + await resetCameraEventCompleters($); + + void onFinished(bool finished) { + cameraAnimationOnFinishedCompleter.complete(); + expect( + finished, + false, + ); // Animation should be cancelled, so finished value should be false. + } + + await viewController.animateCamera( + cancelCameraUpdate, + duration: animationDuration, + onFinished: onFinished, + ); + + // Wait until the camera move has started. + await cameraMoveStartedCompleter.future.timeout( + cameraMoveStartTimeout, + onTimeout: () { + // FIXME(jokerttu): Android does not always trigger cameraMoveStarted event. + // Most likely the native SDK does not trigger it in some cases. + // Skipping failure on Android for now. + if (Platform.isAndroid) { + $.log( + 'Warning: cameraMoveStarted event was not triggered on Android.', + ); + return; + } + fail('cameraMoveStartedCompleter Future timed out'); + }, + ); + + // Drag the map to cancel the animation while it's in progress. + await $.native.swipe( + from: const Offset(0.4, 0.4), + to: const Offset(0.6, 0.6), + ); + await $.pumpAndSettle(); + + // The animation should be cancelled quickly after the drag. + await cameraAnimationOnFinishedCompleter.future.timeout( + cancellationTimeout, + onTimeout: () { + fail('cameraAnimationOnFinishedCompleter Future timed out'); + }, + ); + + final CameraPosition finalPosition = + await viewController.getCameraPosition(); + expect( + finalPosition.target.latitude, + isNot(closeTo(target.latitude, latLngTestThreshold)), + ); + expect( + finalPosition.target.longitude, + isNot(closeTo(target.longitude, latLngTestThreshold)), + ); + }, variant: mapTypeVariants); + + patrol( + 'Test camera animation cancellation by starting new animation', + (PatrolIntegrationTester $) async { + // Long enough duration to ensure first animation is running when we start + // the second one. + const Duration firstAnimationDuration = Duration(seconds: 10); + const Duration secondAnimationDuration = Duration(milliseconds: 100); + // Max time to wait for first animation to start. + const Duration firstAnimationStartTimeout = Duration(seconds: 1); + // Max time to wait for second animation to complete. + const Duration secondAnimationCompleteTimeout = Duration(seconds: 3); + // Max time to wait for first animation to be cancelled. + const Duration firstAnimationCancelTimeout = Duration(seconds: 2); + + const LatLng firstTarget = LatLng( + latitude: startLocationLat + 1, + longitude: startLocationLng + 1, + ); + const LatLng secondTarget = LatLng( + latitude: startLocationLat - 1, + longitude: startLocationLng - 1, + ); + final CameraUpdate startCameraUpdate = CameraUpdate.newLatLngZoom( + LatLng(latitude: startLocationLat, longitude: startLocationLng), + 10, + ); + final CameraUpdate firstCameraUpdate = CameraUpdate.newLatLngZoom( + firstTarget, + 15, + ); + final CameraUpdate secondCameraUpdate = CameraUpdate.newLatLngZoom( + secondTarget, + 12, + ); + + /// Initialize view with the event listener functions. + final GoogleMapViewController viewController = + await getMapViewControllerForTestMapType( + $, + testMapType: mapTypeVariants.currentValue!, + initializeNavigation: false, + simulateLocation: false, + onCameraIdle: onCameraIdle, + onCameraMoveStarted: onCameraMoveStarted, + ); + + // Move to start position + await resetCameraEventCompleters($); + await viewController.moveCamera(startCameraUpdate); + + await cameraIdleCompleter.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + fail('Initial cameraIdleCompleter Future timed out'); + }, + ); + + // Track completion of both animations. + final Completer firstAnimationCompleter = Completer(); + final Completer secondAnimationCompleter = Completer(); + + void onFirstAnimationFinished(bool finished) { + firstAnimationCompleter.complete(finished); + } + + void onSecondAnimationFinished(bool finished) { + secondAnimationCompleter.complete(finished); + } + + await resetCameraEventCompleters($); + + // Start first animation. + await viewController.animateCamera( + firstCameraUpdate, + duration: firstAnimationDuration, + onFinished: onFirstAnimationFinished, + ); + + // Wait until the first animation has started. + await cameraMoveStartedCompleter.future.timeout( + firstAnimationStartTimeout, + onTimeout: () { + // FIXME(jokerttu): Android does not always trigger cameraMoveStarted event. + // Most likely the native SDK does not trigger it in some cases. + // Skipping failure on Android for now. + if (Platform.isAndroid) { + $.log( + 'Warning: cameraMoveStarted event was not triggered on Android.', + ); + return; + } + fail('First animation cameraMoveStartedCompleter Future timed out'); + }, + ); + + // Start second animation while first is still running. + await viewController.animateCamera( + secondCameraUpdate, + duration: secondAnimationDuration, + onFinished: onSecondAnimationFinished, + ); + + // First animation should be cancelled (onFinished called with false) + final bool firstAnimationResult = await firstAnimationCompleter.future + .timeout( + firstAnimationCancelTimeout, + onTimeout: () { + fail('First animation was not cancelled within timeout'); + }, + ); + expect( + firstAnimationResult, + false, + reason: + 'First animation should be cancelled when second animation starts', + ); + + // Second animation should complete successfully; onFinished called + // with `true`. + final bool secondAnimationResult = await secondAnimationCompleter.future + .timeout( + secondAnimationCompleteTimeout, + onTimeout: () { + fail('Second animation did not complete within timeout'); + }, + ); + expect( + secondAnimationResult, + true, + reason: 'Second animation should complete successfully', + ); + }, + variant: mapTypeVariants, + ); } diff --git a/example/lib/pages/camera.dart b/example/lib/pages/camera.dart index a5f5dea3..34a4a66e 100644 --- a/example/lib/pages/camera.dart +++ b/example/lib/pages/camera.dart @@ -14,7 +14,6 @@ // ignore_for_file: public_member_api_docs -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:google_navigation_flutter/google_navigation_flutter.dart'; import '../utils/utils.dart'; @@ -291,7 +290,7 @@ class _CameraPageState extends ExamplePageState { }); }, ), - if (Platform.isAndroid && _animationsEnabled) ...[ + if (_animationsEnabled) ...[ SwitchListTile( title: const Text('Default animation duration'), value: _animationDuration == null, @@ -316,7 +315,7 @@ class _CameraPageState extends ExamplePageState { max: 3000, ), ], - if (Platform.isAndroid && _animationsEnabled && _animationDuration != 0) + if (_animationsEnabled && _animationDuration != 0) SwitchListTile( title: const Text('Display animation finished'), value: _displayAnimationFinished, diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationPlugin.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationPlugin.swift index 9d07e282..f8a8c011 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationPlugin.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationPlugin.swift @@ -74,7 +74,10 @@ public class GoogleMapsNavigationPlugin: NSObject, FlutterPlugin { viewEventApi: viewEventApi!, imageRegistry: imageRegistry! ) - registrar.register(factory, withId: "google_navigation_flutter") + registrar.register( + factory, withId: "google_navigation_flutter", + gestureRecognizersBlockingPolicy: + FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded) navigationSessionEventApi = NavigationSessionEventApi( binaryMessenger: registrar.messenger() diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationView.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationView.swift index a2e9016e..13fbd007 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationView.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationView.swift @@ -50,6 +50,12 @@ public class GoogleMapsNavigationView: NSObject, FlutterPlatformView, ViewSettle var isAttachedToSession: Bool = false private let _isCarPlayView: Bool + /// A token to identify the currently active animation. + private var _currentCameraAnimationToken: NSObject? + /// The completion handler for the currently active camera animation. + private var _currentCameraAnimationCompletion: ((_ finished: Bool) -> Void)? + private var _isGestureCameraMove: Bool = false + // As prompt visibility settings is handled by the navigator, value is // stored here to handle the session attach. On android prompts visibility // is handled by the view. @@ -107,6 +113,7 @@ public class GoogleMapsNavigationView: NSObject, FlutterPlatformView, ViewSettle deinit { unregisterView() + completeCurrentCameraAnimation(finished: false) _mapView.delegate = nil } @@ -358,36 +365,105 @@ public class GoogleMapsNavigationView: NSObject, FlutterPlatformView, ViewSettle GMSCoordinateBounds(region: _mapView.projection.visibleRegion()) } - public func animateCameraToCameraPosition(cameraPosition: GMSCameraPosition) { - _mapView.animate(with: GMSCameraUpdate.setCamera(cameraPosition)) + /// Completes the currently active camera animation if one exists. + private func completeCurrentCameraAnimation(finished: Bool) { + guard _currentCameraAnimationToken != nil else { return } + _currentCameraAnimationToken = nil + if let completion = _currentCameraAnimationCompletion { + _currentCameraAnimationCompletion = nil + completion(finished) + } } - public func animateCameraToLatLng(point: CLLocationCoordinate2D) { - _mapView.animate(with: GMSCameraUpdate.setTarget(point)) + private func performCameraAnimation( + with update: GMSCameraUpdate, + duration: TimeInterval? = nil, + completion: ((_ finished: Bool) -> Void)? = nil + ) { + // Cancel any ongoing camera animation. + completeCurrentCameraAnimation(finished: false) + + let newAnimationToken = NSObject() + _currentCameraAnimationToken = newAnimationToken + _currentCameraAnimationCompletion = completion + + CATransaction.begin() + + // Set custom duration if provided. + if let duration = duration { + CATransaction.setValue(duration, forKey: kCATransactionAnimationDuration) + } + + CATransaction.setCompletionBlock { [weak self] in + guard let self = self else { return } + if self._currentCameraAnimationToken === newAnimationToken { + self.completeCurrentCameraAnimation(finished: true) + } + } + + _mapView.animate(with: update) + _isGestureCameraMove = false + CATransaction.commit() } - public func animateCameraToLatLngBounds(bounds: GMSCoordinateBounds, padding: Double) { - _mapView.animate(with: GMSCameraUpdate.fit(bounds, withPadding: padding)) + public func animateCameraToCameraPosition( + cameraPosition: GMSCameraPosition, duration: TimeInterval? = nil, + completion: ((Bool) -> Void)? = nil + ) { + performCameraAnimation( + with: GMSCameraUpdate.setCamera(cameraPosition), duration: duration, completion: completion) } - public func animateCameraToLatLngZoom(point: CLLocationCoordinate2D, zoom: Double) { - _mapView.animate(with: GMSCameraUpdate.setTarget(point, zoom: Float(zoom))) + public func animateCameraToLatLng( + point: CLLocationCoordinate2D, duration: TimeInterval? = nil, + completion: ((Bool) -> Void)? = nil + ) { + performCameraAnimation( + with: GMSCameraUpdate.setTarget(point), duration: duration, completion: completion) } - public func animateCameraByScroll(dx: Double, dy: Double) { - _mapView.animate(with: GMSCameraUpdate.scrollBy(x: CGFloat(dx), y: CGFloat(dy))) + public func animateCameraToLatLngBounds( + bounds: GMSCoordinateBounds, padding: Double, duration: TimeInterval? = nil, + completion: ((Bool) -> Void)? = nil + ) { + performCameraAnimation( + with: GMSCameraUpdate.fit(bounds, withPadding: padding), duration: duration, + completion: completion) } - public func animateCameraByZoom(zoomBy: Double, focus: CGPoint?) { - if focus != nil { - _mapView.animate(with: GMSCameraUpdate.zoom(by: Float(zoomBy), at: focus!)) - } else { - _mapView.animate(with: GMSCameraUpdate.zoom(by: Float(zoomBy))) - } + public func animateCameraToLatLngZoom( + point: CLLocationCoordinate2D, zoom: Double, duration: TimeInterval? = nil, + completion: ((Bool) -> Void)? = nil + ) { + performCameraAnimation( + with: GMSCameraUpdate.setTarget(point, zoom: Float(zoom)), duration: duration, + completion: completion) } - public func animateCameraToZoom(zoom: Double) { - _mapView.animate(with: GMSCameraUpdate.zoom(to: Float(zoom))) + public func animateCameraByScroll( + dx: Double, dy: Double, duration: TimeInterval? = nil, completion: ((Bool) -> Void)? = nil + ) { + performCameraAnimation( + with: GMSCameraUpdate.scrollBy(x: CGFloat(dx), y: CGFloat(dy)), duration: duration, + completion: completion) + } + + public func animateCameraByZoom( + zoomBy: Double, focus: CGPoint?, duration: TimeInterval? = nil, + completion: ((Bool) -> Void)? = nil + ) { + let update = + focus != nil + ? GMSCameraUpdate.zoom(by: Float(zoomBy), at: focus!) + : GMSCameraUpdate.zoom(by: Float(zoomBy)) + performCameraAnimation(with: update, duration: duration, completion: completion) + } + + public func animateCameraToZoom( + zoom: Double, duration: TimeInterval? = nil, completion: ((Bool) -> Void)? = nil + ) { + performCameraAnimation( + with: GMSCameraUpdate.zoom(to: Float(zoom)), duration: duration, completion: completion) } public func moveCameraToCameraPosition(cameraPosition: GMSCameraPosition) { @@ -901,6 +977,7 @@ extension GoogleMapsNavigationView: GMSMapViewNavigationUIDelegate { extension GoogleMapsNavigationView: GMSMapViewDelegate { public func mapView(_ mapView: GMSMapView, didTapAt coordinate: CLLocationCoordinate2D) { + _isGestureCameraMove = false getViewEventApi()?.onMapClickEvent( viewId: _viewId!, latLng: .init( @@ -912,6 +989,7 @@ extension GoogleMapsNavigationView: GMSMapViewDelegate { } public func mapView(_ mapView: GMSMapView, didLongPressAt coordinate: CLLocationCoordinate2D) { + _isGestureCameraMove = false getViewEventApi()?.onMapLongClickEvent( viewId: _viewId!, latLng: .init( @@ -993,6 +1071,7 @@ extension GoogleMapsNavigationView: GMSMapViewDelegate { } public func mapView(_ mapView: GMSMapView, willMove gesture: Bool) { + _isGestureCameraMove = gesture if _listenCameraChanges { let position = Convert.convertCameraPosition(position: mapView.camera) getViewEventApi()?.onCameraChanged( @@ -1005,6 +1084,9 @@ extension GoogleMapsNavigationView: GMSMapViewDelegate { } public func mapView(_ mapView: GMSMapView, idleAt position: GMSCameraPosition) { + // If there's an active animation, complete it when camera becomes idle. + completeCurrentCameraAnimation(finished: true) + if _listenCameraChanges { getViewEventApi()?.onCameraChanged( viewId: _viewId!, @@ -1016,6 +1098,9 @@ extension GoogleMapsNavigationView: GMSMapViewDelegate { } public func mapView(_ mapView: GMSMapView, didChange position: GMSCameraPosition) { + if _isGestureCameraMove { + completeCurrentCameraAnimation(finished: false) + } if _listenCameraChanges { getViewEventApi()?.onCameraChanged( viewId: _viewId!, diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewMessageHandler.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewMessageHandler.swift index c0acc666..6b691312 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewMessageHandler.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewMessageHandler.swift @@ -178,14 +178,16 @@ class GoogleMapsNavigationViewMessageHandler: MapViewApi { completion: @escaping (Result) -> Void ) { do { + let durationInSeconds = duration != nil ? TimeInterval(duration!) / 1000.0 : nil try getView(viewId) .animateCameraToCameraPosition( cameraPosition: Convert - .convertCameraPosition(position: cameraPosition)) - - // No callback supported, just return immediately - completion(.success(true)) + .convertCameraPosition(position: cameraPosition), + duration: durationInSeconds + ) { success in + completion(.success(success)) + } } catch { completion(.failure(error)) } @@ -196,10 +198,13 @@ class GoogleMapsNavigationViewMessageHandler: MapViewApi { completion: @escaping (Result) -> Void ) { do { - try getView(viewId).animateCameraToLatLng(point: Convert.convertLatLngFromDto(point: point)) - - // No callback supported, just return immediately - completion(.success(true)) + let durationInSeconds = duration != nil ? TimeInterval(duration!) / 1000.0 : nil + try getView(viewId).animateCameraToLatLng( + point: Convert.convertLatLngFromDto(point: point), + duration: durationInSeconds + ) { success in + completion(.success(success)) + } } catch { completion(.failure(error)) } @@ -217,13 +222,14 @@ class GoogleMapsNavigationViewMessageHandler: MapViewApi { ) -> Void ) { do { + let durationInSeconds = duration != nil ? TimeInterval(duration!) / 1000.0 : nil try getView(viewId).animateCameraToLatLngBounds( bounds: Convert.convertLatLngBounds(bounds: bounds), - padding: padding - ) - - // No callback supported, just return immediately - completion(.success(true)) + padding: padding, + duration: durationInSeconds + ) { success in + completion(.success(success)) + } } catch { completion(.failure(error)) } @@ -235,13 +241,14 @@ class GoogleMapsNavigationViewMessageHandler: MapViewApi { completion: @escaping (Result) -> Void ) { do { + let durationInSeconds = duration != nil ? TimeInterval(duration!) / 1000.0 : nil try getView(viewId).animateCameraToLatLngZoom( point: Convert.convertLatLngFromDto(point: point), - zoom: zoom - ) - - // No callback supported, just return immediately - completion(.success(true)) + zoom: zoom, + duration: durationInSeconds + ) { success in + completion(.success(success)) + } } catch { completion(.failure(error)) } @@ -253,10 +260,14 @@ class GoogleMapsNavigationViewMessageHandler: MapViewApi { completion: @escaping (Result) -> Void ) { do { - try getView(viewId).animateCameraByScroll(dx: scrollByDx, dy: scrollByDy) - - // No callback supported, just return immediately - completion(.success(true)) + let durationInSeconds = duration != nil ? TimeInterval(duration!) / 1000.0 : nil + try getView(viewId).animateCameraByScroll( + dx: scrollByDx, + dy: scrollByDy, + duration: durationInSeconds + ) { success in + completion(.success(success)) + } } catch { completion(.failure(error)) } @@ -274,11 +285,15 @@ class GoogleMapsNavigationViewMessageHandler: MapViewApi { ) -> Void ) { do { + let durationInSeconds = duration != nil ? TimeInterval(duration!) / 1000.0 : nil let focus = Convert.convertDeltaToPoint(dx: focusDx, dy: focusDy) - try getView(viewId).animateCameraByZoom(zoomBy: zoomBy, focus: focus) - - // No callback supported, just return immediately - completion(.success(true)) + try getView(viewId).animateCameraByZoom( + zoomBy: zoomBy, + focus: focus, + duration: durationInSeconds + ) { success in + completion(.success(success)) + } } catch { completion(.failure(error)) } @@ -289,10 +304,13 @@ class GoogleMapsNavigationViewMessageHandler: MapViewApi { completion: @escaping (Result) -> Void ) { do { - try getView(viewId).animateCameraToZoom(zoom: zoom) - - // No callback supported, just return immediately - completion(.success(true)) + let durationInSeconds = duration != nil ? TimeInterval(duration!) / 1000.0 : nil + try getView(viewId).animateCameraToZoom( + zoom: zoom, + duration: durationInSeconds + ) { success in + completion(.success(success)) + } } catch { completion(.failure(error)) } diff --git a/lib/src/google_maps_map_view_controller.dart b/lib/src/google_maps_map_view_controller.dart index 55225507..d67b64a1 100644 --- a/lib/src/google_maps_map_view_controller.dart +++ b/lib/src/google_maps_map_view_controller.dart @@ -133,8 +133,8 @@ class GoogleMapViewController { /// See [CameraUpdate] for more information on how to create different camera /// animations. /// - /// On Android you can override the default animation [duration] and - /// set [onFinished] callback that is called when the animation completes + /// The default animation [duration] can be overridden and an [onFinished] + /// callback can be set that is called when the animation completes /// (passes true) or is cancelled (passes false). /// /// Example usage: @@ -143,8 +143,6 @@ class GoogleMapViewController { /// duration: Duration(milliseconds: 600), /// onFinished: (bool success) => {}); /// ``` - /// On iOS [duration] and [onFinished] are not supported and defining them - /// does nothing. /// /// See also [moveCamera], [followMyLocation]. Future animateCamera( diff --git a/lib/src/method_channel/map_view_api.dart b/lib/src/method_channel/map_view_api.dart index b6a9e007..411f17a9 100644 --- a/lib/src/method_channel/map_view_api.dart +++ b/lib/src/method_channel/map_view_api.dart @@ -13,7 +13,6 @@ // limitations under the License. import 'dart:async'; -import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -425,12 +424,7 @@ class MapViewAPIImpl { cameraUpdate.cameraPosition!.toCameraPosition(), duration, ) - .then( - (bool success) => - onFinished != null && Platform.isAndroid - ? onFinished(success) - : null, - ), + .then((bool success) => onFinished?.call(success)), ); case CameraUpdateType.latLng: unawaited( @@ -440,12 +434,7 @@ class MapViewAPIImpl { cameraUpdate.latLng!.toDto(), duration, ) - .then( - (bool success) => - onFinished != null && Platform.isAndroid - ? onFinished(success) - : null, - ), + .then((bool success) => onFinished?.call(success)), ); case CameraUpdateType.latLngBounds: unawaited( @@ -456,12 +445,7 @@ class MapViewAPIImpl { cameraUpdate.padding!, duration, ) - .then( - (bool success) => - onFinished != null && Platform.isAndroid - ? onFinished(success) - : null, - ), + .then((bool success) => onFinished?.call(success)), ); case CameraUpdateType.latLngZoom: unawaited( @@ -472,12 +456,7 @@ class MapViewAPIImpl { cameraUpdate.zoom!, duration, ) - .then( - (bool success) => - onFinished != null && Platform.isAndroid - ? onFinished(success) - : null, - ), + .then((bool success) => onFinished?.call(success)), ); case CameraUpdateType.scrollBy: unawaited( @@ -488,12 +467,7 @@ class MapViewAPIImpl { cameraUpdate.scrollByDy!, duration, ) - .then( - (bool success) => - onFinished != null && Platform.isAndroid - ? onFinished(success) - : null, - ), + .then((bool success) => onFinished?.call(success)), ); case CameraUpdateType.zoomBy: unawaited( @@ -505,23 +479,13 @@ class MapViewAPIImpl { cameraUpdate.focus?.dy, duration, ) - .then( - (bool success) => - onFinished != null && Platform.isAndroid - ? onFinished(success) - : null, - ), + .then((bool success) => onFinished?.call(success)), ); case CameraUpdateType.zoomTo: unawaited( _viewApi .animateCameraToZoom(viewId, cameraUpdate.zoom!, duration) - .then( - (bool success) => - onFinished != null && Platform.isAndroid - ? onFinished(success) - : null, - ), + .then((bool success) => onFinished?.call(success)), ); } }