Skip to content

Commit 125c117

Browse files
authored
[google_maps_flutter] Add ability to animate camera with duration (#7648)
Adds ability to configure camera animation duration on Android and iOS. Resolves [#39810](flutter/flutter#39810) Resolves [#44284](flutter/flutter#44284)
1 parent 527d8fa commit 125c117

File tree

8 files changed

+494
-11
lines changed

8 files changed

+494
-11
lines changed

packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.12.0
2+
3+
* Adds support for animating the camera with a duration.
4+
15
## 2.11.0
26

37
* Adds support for ground overlays.

packages/google_maps_flutter/google_maps_flutter/example/integration_test/src/maps_inspector.dart

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ import 'package:integration_test/integration_test.dart';
1313

1414
import 'shared.dart';
1515

16+
const double _kTestCameraZoomLevel = 10;
17+
const double _kTestZoomByAmount = 2;
18+
const LatLng _kTestMapCenter = LatLng(65, 25.5);
19+
const CameraPosition _kTestCameraPosition = CameraPosition(
20+
target: _kTestMapCenter,
21+
zoom: _kTestCameraZoomLevel,
22+
bearing: 1.0,
23+
tilt: 1.0,
24+
);
25+
final LatLngBounds _testCameraBounds = LatLngBounds(
26+
northeast: const LatLng(50, -65), southwest: const LatLng(28.5, -123));
27+
final ValueVariant<CameraUpdateType> _cameraUpdateTypeVariants =
28+
ValueVariant<CameraUpdateType>(CameraUpdateType.values.toSet());
29+
1630
/// Integration Tests that use the [GoogleMapsInspectorPlatform].
1731
void main() {
1832
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -612,6 +626,248 @@ void runTests() {
612626
expect(clusters.length, 0);
613627
}
614628
});
629+
630+
testWidgets(
631+
'testAnimateCameraWithoutDuration',
632+
(WidgetTester tester) async {
633+
final Key key = GlobalKey();
634+
final Completer<GoogleMapController> controllerCompleter =
635+
Completer<GoogleMapController>();
636+
final GoogleMapsInspectorPlatform inspector =
637+
GoogleMapsInspectorPlatform.instance!;
638+
639+
/// Completer to track when the camera has come to rest.
640+
Completer<void>? cameraIdleCompleter;
641+
642+
await tester.pumpWidget(Directionality(
643+
textDirection: TextDirection.ltr,
644+
child: GoogleMap(
645+
key: key,
646+
initialCameraPosition: kInitialCameraPosition,
647+
onCameraIdle: () {
648+
if (cameraIdleCompleter != null &&
649+
!cameraIdleCompleter.isCompleted) {
650+
cameraIdleCompleter.complete();
651+
}
652+
},
653+
onMapCreated: (GoogleMapController controller) {
654+
controllerCompleter.complete(controller);
655+
},
656+
),
657+
));
658+
659+
final GoogleMapController controller = await controllerCompleter.future;
660+
661+
await tester.pumpAndSettle();
662+
// TODO(cyanglaz): Remove this after we added `mapRendered` callback, and
663+
// `mapControllerCompleter.complete(controller)` above should happen in
664+
// `mapRendered`.
665+
// https://github.com/flutter/flutter/issues/54758
666+
await Future<void>.delayed(const Duration(seconds: 1));
667+
668+
// Create completer for camera idle event.
669+
cameraIdleCompleter = Completer<void>();
670+
671+
final CameraUpdate cameraUpdate =
672+
_getCameraUpdateForType(_cameraUpdateTypeVariants.currentValue!);
673+
await controller.animateCamera(cameraUpdate);
674+
675+
// If platform supportes getting camera position, check that the camera
676+
// has moved as expected.
677+
CameraPosition? beforeFinishedPosition;
678+
if (inspector.supportsGettingGameraPosition()) {
679+
// Immediately after calling animateCamera, check that the camera hasn't
680+
// reached its final position. This relies on the assumption that the
681+
// camera move is animated and won't complete instantly.
682+
beforeFinishedPosition =
683+
await inspector.getCameraPosition(mapId: controller.mapId);
684+
685+
await _checkCameraUpdateByType(
686+
_cameraUpdateTypeVariants.currentValue!,
687+
beforeFinishedPosition,
688+
null,
689+
controller,
690+
(Matcher matcher) => isNot(matcher));
691+
}
692+
693+
// Wait for the animation to complete (onCameraIdle).
694+
expect(cameraIdleCompleter.isCompleted, isFalse);
695+
await cameraIdleCompleter.future;
696+
697+
// If platform supportes getting camera position, check that the camera
698+
// has moved as expected.
699+
if (inspector.supportsGettingGameraPosition()) {
700+
// After onCameraIdle event, the camera should be at the final position.
701+
final CameraPosition afterFinishedPosition =
702+
await inspector.getCameraPosition(mapId: controller.mapId);
703+
await _checkCameraUpdateByType(
704+
_cameraUpdateTypeVariants.currentValue!,
705+
afterFinishedPosition,
706+
beforeFinishedPosition,
707+
controller,
708+
(Matcher matcher) => matcher);
709+
}
710+
},
711+
variant: _cameraUpdateTypeVariants,
712+
// TODO(stuartmorgan): Remove skip for Android platform once Maps API key is
713+
// available for LUCI, https://github.com/flutter/flutter/issues/131071
714+
skip: isAndroid,
715+
);
716+
717+
/// Tests animating the camera with specified durations to verify timing
718+
/// behavior.
719+
///
720+
/// This test checks two scenarios: short and long animation durations.
721+
/// It uses a midpoint duration to ensure the short animation completes in
722+
/// less time and the long animation takes more time than that midpoint.
723+
/// This ensures that the animation duration is respected by the platform and
724+
/// that the default camera animation duration does not affect the test
725+
/// results.
726+
testWidgets(
727+
'testAnimateCameraWithDuration',
728+
(WidgetTester tester) async {
729+
final Key key = GlobalKey();
730+
final Completer<GoogleMapController> controllerCompleter =
731+
Completer<GoogleMapController>();
732+
final GoogleMapsInspectorPlatform inspector =
733+
GoogleMapsInspectorPlatform.instance!;
734+
735+
/// Completer to track when the camera has come to rest.
736+
Completer<void>? cameraIdleCompleter;
737+
738+
const int shortCameraAnimationDurationMS = 200;
739+
const int longCameraAnimationDurationMS = 1000;
740+
741+
/// Calculate the midpoint duration of the animation test, which will
742+
/// serve as a reference to verify that animations complete more quickly
743+
/// with shorter durations and more slowly with longer durations.
744+
const int animationDurationMiddlePoint =
745+
(shortCameraAnimationDurationMS + longCameraAnimationDurationMS) ~/ 2;
746+
747+
// Stopwatch to measure the time taken for the animation to complete.
748+
final Stopwatch stopwatch = Stopwatch();
749+
750+
await tester.pumpWidget(Directionality(
751+
textDirection: TextDirection.ltr,
752+
child: GoogleMap(
753+
key: key,
754+
initialCameraPosition: kInitialCameraPosition,
755+
onCameraIdle: () {
756+
if (cameraIdleCompleter != null &&
757+
!cameraIdleCompleter.isCompleted) {
758+
stopwatch.stop();
759+
cameraIdleCompleter.complete();
760+
}
761+
},
762+
onMapCreated: (GoogleMapController controller) {
763+
controllerCompleter.complete(controller);
764+
},
765+
),
766+
));
767+
768+
final GoogleMapController controller = await controllerCompleter.future;
769+
770+
await tester.pumpAndSettle();
771+
// TODO(cyanglaz): Remove this after we added `mapRendered` callback, and
772+
// `mapControllerCompleter.complete(controller)` above should happen in
773+
// `mapRendered`.
774+
// https://github.com/flutter/flutter/issues/54758
775+
await Future<void>.delayed(const Duration(seconds: 1));
776+
777+
// Create completer for camera idle event.
778+
cameraIdleCompleter = Completer<void>();
779+
780+
// Start stopwatch to check the time taken for the animation to complete.
781+
// Stopwatch is stopped on camera idle callback.
782+
stopwatch.reset();
783+
stopwatch.start();
784+
785+
// First phase with shorter animation duration.
786+
final CameraUpdate cameraUpdateShort =
787+
_getCameraUpdateForType(_cameraUpdateTypeVariants.currentValue!);
788+
await controller.animateCamera(
789+
cameraUpdateShort,
790+
duration: const Duration(milliseconds: shortCameraAnimationDurationMS),
791+
);
792+
793+
// Wait for the animation to complete (onCameraIdle).
794+
expect(cameraIdleCompleter.isCompleted, isFalse);
795+
await cameraIdleCompleter.future;
796+
797+
// For short animation duration, check that the animation is completed
798+
// faster than the midpoint benchmark.
799+
expect(stopwatch.elapsedMilliseconds,
800+
lessThan(animationDurationMiddlePoint));
801+
802+
// Reset camera to initial position before testing long duration.
803+
await controller
804+
.moveCamera(CameraUpdate.newCameraPosition(kInitialCameraPosition));
805+
await tester.pumpAndSettle();
806+
807+
// Create completer for camera idle event.
808+
cameraIdleCompleter = Completer<void>();
809+
810+
// Start stopwatch to check the time taken for the animation to complete.
811+
// Stopwatch is stopped on camera idle callback.
812+
stopwatch.reset();
813+
stopwatch.start();
814+
815+
// Second phase with longer animation duration.
816+
final CameraUpdate cameraUpdateLong =
817+
_getCameraUpdateForType(_cameraUpdateTypeVariants.currentValue!);
818+
await controller.animateCamera(
819+
cameraUpdateLong,
820+
duration: const Duration(milliseconds: longCameraAnimationDurationMS),
821+
);
822+
823+
// If platform supportes getting camera position, check that the camera
824+
// has moved as expected.
825+
CameraPosition? beforeFinishedPosition;
826+
if (inspector.supportsGettingGameraPosition()) {
827+
// Immediately after calling animateCamera, check that the camera hasn't
828+
// reached its final position. This relies on the assumption that the
829+
// camera move is animated and won't complete instantly.
830+
beforeFinishedPosition =
831+
await inspector.getCameraPosition(mapId: controller.mapId);
832+
833+
await _checkCameraUpdateByType(
834+
_cameraUpdateTypeVariants.currentValue!,
835+
beforeFinishedPosition,
836+
null,
837+
controller,
838+
(Matcher matcher) => isNot(matcher));
839+
}
840+
841+
// Wait for the animation to complete (onCameraIdle).
842+
expect(cameraIdleCompleter.isCompleted, isFalse);
843+
await cameraIdleCompleter.future;
844+
845+
// For longer animation duration, check that the animation is completed
846+
// slower than the midpoint benchmark.
847+
expect(stopwatch.elapsedMilliseconds,
848+
greaterThan(animationDurationMiddlePoint));
849+
850+
// If platform supportes getting camera position, check that the camera
851+
// has moved as expected.
852+
if (inspector.supportsGettingGameraPosition()) {
853+
// Camera should be at the final position.
854+
final CameraPosition afterFinishedPosition =
855+
await inspector.getCameraPosition(mapId: controller.mapId);
856+
await _checkCameraUpdateByType(
857+
_cameraUpdateTypeVariants.currentValue!,
858+
afterFinishedPosition,
859+
beforeFinishedPosition,
860+
controller,
861+
(Matcher matcher) => matcher);
862+
}
863+
},
864+
variant: _cameraUpdateTypeVariants,
865+
// TODO(jokerttu): Remove skip once the web implementation is available,
866+
// https://github.com/flutter/flutter/issues/159265
867+
// TODO(stuartmorgan): Remove skip for Android platform once Maps API key is
868+
// available for LUCI, https://github.com/flutter/flutter/issues/131071
869+
skip: kIsWeb || isAndroid,
870+
);
615871
}
616872

617873
Marker _copyMarkerWithClusterManagerId(
@@ -636,3 +892,89 @@ Marker _copyMarkerWithClusterManagerId(
636892
clusterManagerId: clusterManagerId,
637893
);
638894
}
895+
896+
CameraUpdate _getCameraUpdateForType(CameraUpdateType type) {
897+
return switch (type) {
898+
CameraUpdateType.newCameraPosition =>
899+
CameraUpdate.newCameraPosition(_kTestCameraPosition),
900+
CameraUpdateType.newLatLng => CameraUpdate.newLatLng(_kTestMapCenter),
901+
CameraUpdateType.newLatLngBounds =>
902+
CameraUpdate.newLatLngBounds(_testCameraBounds, 0),
903+
CameraUpdateType.newLatLngZoom =>
904+
CameraUpdate.newLatLngZoom(_kTestMapCenter, _kTestCameraZoomLevel),
905+
CameraUpdateType.scrollBy => CameraUpdate.scrollBy(10, 10),
906+
CameraUpdateType.zoomBy =>
907+
CameraUpdate.zoomBy(_kTestZoomByAmount, const Offset(1, 1)),
908+
CameraUpdateType.zoomTo => CameraUpdate.zoomTo(_kTestCameraZoomLevel),
909+
CameraUpdateType.zoomIn => CameraUpdate.zoomIn(),
910+
CameraUpdateType.zoomOut => CameraUpdate.zoomOut(),
911+
};
912+
}
913+
914+
Future<void> _checkCameraUpdateByType(
915+
CameraUpdateType type,
916+
CameraPosition currentPosition,
917+
CameraPosition? oldPosition,
918+
GoogleMapController controller,
919+
Matcher Function(Matcher matcher) wrapMatcher,
920+
) async {
921+
// As the target might differ a bit from the expected target, a threshold is
922+
// used.
923+
const double latLngThreshold = 0.05;
924+
925+
switch (type) {
926+
case CameraUpdateType.newCameraPosition:
927+
expect(currentPosition.bearing,
928+
wrapMatcher(equals(_kTestCameraPosition.bearing)));
929+
expect(
930+
currentPosition.zoom, wrapMatcher(equals(_kTestCameraPosition.zoom)));
931+
expect(
932+
currentPosition.tilt, wrapMatcher(equals(_kTestCameraPosition.tilt)));
933+
expect(
934+
currentPosition.target.latitude,
935+
wrapMatcher(
936+
closeTo(_kTestCameraPosition.target.latitude, latLngThreshold)));
937+
expect(
938+
currentPosition.target.longitude,
939+
wrapMatcher(
940+
closeTo(_kTestCameraPosition.target.longitude, latLngThreshold)));
941+
case CameraUpdateType.newLatLng:
942+
expect(currentPosition.target.latitude,
943+
wrapMatcher(closeTo(_kTestMapCenter.latitude, latLngThreshold)));
944+
expect(currentPosition.target.longitude,
945+
wrapMatcher(closeTo(_kTestMapCenter.longitude, latLngThreshold)));
946+
case CameraUpdateType.newLatLngBounds:
947+
final LatLngBounds bounds = await controller.getVisibleRegion();
948+
expect(
949+
bounds.northeast.longitude,
950+
wrapMatcher(
951+
closeTo(_testCameraBounds.northeast.longitude, latLngThreshold)));
952+
expect(
953+
bounds.southwest.longitude,
954+
wrapMatcher(
955+
closeTo(_testCameraBounds.southwest.longitude, latLngThreshold)));
956+
case CameraUpdateType.newLatLngZoom:
957+
expect(currentPosition.target.latitude,
958+
wrapMatcher(closeTo(_kTestMapCenter.latitude, latLngThreshold)));
959+
expect(currentPosition.target.longitude,
960+
wrapMatcher(closeTo(_kTestMapCenter.longitude, latLngThreshold)));
961+
expect(currentPosition.zoom, wrapMatcher(equals(_kTestCameraZoomLevel)));
962+
case CameraUpdateType.scrollBy:
963+
// For scrollBy, just check that the location has changed.
964+
if (oldPosition != null) {
965+
expect(currentPosition.target.latitude,
966+
isNot(equals(oldPosition.target.latitude)));
967+
expect(currentPosition.target.longitude,
968+
isNot(equals(oldPosition.target.longitude)));
969+
}
970+
case CameraUpdateType.zoomBy:
971+
expect(currentPosition.zoom,
972+
wrapMatcher(equals(kInitialZoomLevel + _kTestZoomByAmount)));
973+
case CameraUpdateType.zoomTo:
974+
expect(currentPosition.zoom, wrapMatcher(equals(_kTestCameraZoomLevel)));
975+
case CameraUpdateType.zoomIn:
976+
expect(currentPosition.zoom, wrapMatcher(equals(kInitialZoomLevel + 1)));
977+
case CameraUpdateType.zoomOut:
978+
expect(currentPosition.zoom, wrapMatcher(equals(kInitialZoomLevel - 1)));
979+
}
980+
}

0 commit comments

Comments
 (0)