Skip to content

Commit

Permalink
feat: add ability to animate camera with duration
Browse files Browse the repository at this point in the history
  • Loading branch information
jokerttu committed Sep 14, 2024
1 parent 100a074 commit 68e52df
Show file tree
Hide file tree
Showing 50 changed files with 2,021 additions and 298 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ android {

defaultConfig {
applicationId "io.flutter.plugins.googlemapsexample"
minSdkVersion 20
minSdkVersion flutter.minSdkVersion
targetSdkVersion 28
multiDexEnabled true
versionCode flutterVersionCode.toInteger()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import 'dart:async';

import 'package:async/async.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Expand All @@ -13,6 +14,18 @@ import 'package:integration_test/integration_test.dart';

import 'shared.dart';

const double _kTestCameraZoomLevel = 10;
const double _kTestZoombByAmount = 2;
const LatLng _kTestMapCenter = LatLng(65, 25.5);
const CameraPosition _kTestCameraPosition = CameraPosition(
target: _kTestMapCenter,
zoom: _kTestCameraZoomLevel,
bearing: 1.0,
tilt: 1.0,
);
final LatLngBounds _kTestCameraBounds = LatLngBounds(
northeast: const LatLng(50, -65), southwest: const LatLng(28.5, -123));

/// Integration Tests that use the [GoogleMapsInspectorPlatform].
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
Expand Down Expand Up @@ -612,6 +625,176 @@ void runTests() {
expect(clusters.length, 0);
}
});

testWidgets('testAnimateCamera', (WidgetTester tester) async {
final Key key = GlobalKey();
final Completer<GoogleMapController> controllerCompleter =
Completer<GoogleMapController>();
final GoogleMapsInspectorPlatform inspector =
GoogleMapsInspectorPlatform.instance!;

final StreamController<void> cameraFiredStream = StreamController<void>();
final StreamQueue<void> cameraFiredQueue =
StreamQueue<void>(cameraFiredStream.stream);

bool cameraIdleFired = false;

await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: GoogleMap(
key: key,
initialCameraPosition: kInitialCameraPosition,
onCameraIdle: () {
cameraIdleFired = true;
cameraFiredStream.add(null);
},
onMapCreated: (GoogleMapController controller) {
controllerCompleter.complete(controller);
},
),
));

final GoogleMapController controller = await controllerCompleter.future;

await tester.pumpAndSettle();
// TODO(cyanglaz): Remove this after we added `mapRendered` callback, and `mapControllerCompleter.complete(controller)` above should happen
// in `mapRendered`.
// https://github.com/flutter/flutter/issues/54758
await Future<void>.delayed(const Duration(seconds: 1));

for (final CameraUpdateType type in CameraUpdateType.values) {
// Reset the camera position.
await controller
.moveCamera(CameraUpdate.newCameraPosition(kInitialCameraPosition));

// Drain any event that might have been added from the initial setup or
// resetting the camera position.
if (cameraIdleFired) {
await cameraFiredQueue.next;
cameraIdleFired = false;
}

final CameraUpdate cameraUpdate = _getCameraUpdateForType(type);
await controller.animateCamera(cameraUpdate);

// Check that position is not updated immediately to the target.
final CameraPosition beforeFinishedPosition =
await inspector.getCameraPosition(mapId: controller.mapId);
await _checkCameraUpdateByType(type, beforeFinishedPosition, null,
controller, (Matcher matcher) => isNot(matcher));

// Check that position is animated after the animation is done.
expect(cameraIdleFired, isFalse);
await cameraFiredQueue.next;
expect(cameraIdleFired, isTrue);
final CameraPosition afterFinishedPosition =
await inspector.getCameraPosition(mapId: controller.mapId);
await _checkCameraUpdateByType(type, afterFinishedPosition,
beforeFinishedPosition, controller, (Matcher matcher) => matcher);

await tester.pumpAndSettle();
}
});

testWidgets('testAnimateCameraWithConfiguration',
(WidgetTester tester) async {
final Key key = GlobalKey();
final Completer<GoogleMapController> controllerCompleter =
Completer<GoogleMapController>();
final GoogleMapsInspectorPlatform inspector =
GoogleMapsInspectorPlatform.instance!;

final StreamController<void> cameraFiredStream = StreamController<void>();
final StreamQueue<void> cameraFiredQueue =
StreamQueue<void>(cameraFiredStream.stream);

// We set the duration to 100ms and check that the animation is completed
// faster than the normal animation (~300ms on Android) to make sure the
// configuration is really used.
const int cameraAnimationDurationMS = 100;

// The threshold is set to 200ms because the normal camera animation should
// take more than 200ms on android.
const int animationCheckThreshold = 200;

// Stopwatch to measure the time taken for the animation to complete.
final Stopwatch stopwatch = Stopwatch();

const CameraUpdateAnimationConfiguration configuration =
CameraUpdateAnimationConfiguration(
duration: Duration(milliseconds: cameraAnimationDurationMS),
);

bool cameraIdleFired = false;

await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: GoogleMap(
key: key,
initialCameraPosition: kInitialCameraPosition,
onCameraIdle: () {
stopwatch.stop();
cameraIdleFired = true;
cameraFiredStream.add(null);
},
onMapCreated: (GoogleMapController controller) {
controllerCompleter.complete(controller);
},
),
));

final GoogleMapController controller = await controllerCompleter.future;

await tester.pumpAndSettle();
// TODO(cyanglaz): Remove this after we added `mapRendered` callback, and `mapControllerCompleter.complete(controller)` above should happen
// in `mapRendered`.
// https://github.com/flutter/flutter/issues/54758
await Future<void>.delayed(const Duration(seconds: 1));

for (final CameraUpdateType type in CameraUpdateType.values) {
// Reset the camera position.
await controller
.moveCamera(CameraUpdate.newCameraPosition(kInitialCameraPosition));

// Drain any event that might have been added from the initial setup or
// resetting the camera position.
if (cameraIdleFired) {
await cameraFiredQueue.next;
cameraIdleFired = false;
}

// Start stopwatch to check the time taken for the animation to complete.
// Stopwatch is stopped on camera idle callback.
stopwatch.reset();
stopwatch.start();

final CameraUpdate cameraUpdate = _getCameraUpdateForType(type);
await controller.animateCamera(cameraUpdate,
configuration: configuration);

// Check that position is not updated immediately to the target.
final CameraPosition beforeFinishedPosition =
await inspector.getCameraPosition(mapId: controller.mapId);
await _checkCameraUpdateByType(type, beforeFinishedPosition, null,
controller, (Matcher matcher) => isNot(matcher));

// Wait for the camera idle callback to fire.
expect(cameraIdleFired, isFalse);
await cameraFiredQueue.next;
expect(cameraIdleFired, isTrue);

// Check that the animation is completed faster than the normal animation.
expect(stopwatch.elapsedMilliseconds, lessThan(animationCheckThreshold));

// Check that position is animated after the animation is done.
final CameraPosition afterFinishedPosition =
await inspector.getCameraPosition(mapId: controller.mapId);
await _checkCameraUpdateByType(type, afterFinishedPosition,
beforeFinishedPosition, controller, (Matcher matcher) => matcher);

await tester.pumpAndSettle();
}
}, skip: kIsWeb);
}

Marker _copyMarkerWithClusterManagerId(
Expand All @@ -636,3 +819,94 @@ Marker _copyMarkerWithClusterManagerId(
clusterManagerId: clusterManagerId,
);
}

CameraUpdate _getCameraUpdateForType(CameraUpdateType type) {
switch (type) {
case CameraUpdateType.newCameraPosition:
return CameraUpdate.newCameraPosition(_kTestCameraPosition);
case CameraUpdateType.newLatLng:
return CameraUpdate.newLatLng(_kTestMapCenter);
case CameraUpdateType.newLatLngBounds:
return CameraUpdate.newLatLngBounds(_kTestCameraBounds, 0);
case CameraUpdateType.newLatLngZoom:
return CameraUpdate.newLatLngZoom(_kTestMapCenter, _kTestCameraZoomLevel);
case CameraUpdateType.scrollBy:
return CameraUpdate.scrollBy(10, 10);
case CameraUpdateType.zoomBy:
return CameraUpdate.zoomBy(_kTestZoombByAmount, const Offset(1, 1));
case CameraUpdateType.zoomTo:
return CameraUpdate.zoomTo(_kTestCameraZoomLevel);
case CameraUpdateType.zoomIn:
return CameraUpdate.zoomIn();
case CameraUpdateType.zoomOut:
return CameraUpdate.zoomOut();
}
}

Future<void> _checkCameraUpdateByType(
CameraUpdateType type,
CameraPosition currentPosition,
CameraPosition? oldPosition,
GoogleMapController controller,
Matcher Function(Matcher matcher) wrapMatcher,
) async {
// For some reason the target might differ a bit from the expected target.
// This is why we use a threshold for the target.
const double latLngThreshold = 0.05;

switch (type) {
case CameraUpdateType.newCameraPosition:
expect(currentPosition.bearing,
wrapMatcher(equals(_kTestCameraPosition.bearing)));
expect(
currentPosition.zoom, wrapMatcher(equals(_kTestCameraPosition.zoom)));
expect(
currentPosition.tilt, wrapMatcher(equals(_kTestCameraPosition.tilt)));
expect(
currentPosition.target.latitude,
wrapMatcher(
closeTo(_kTestCameraPosition.target.latitude, latLngThreshold)));
expect(
currentPosition.target.longitude,
wrapMatcher(
closeTo(_kTestCameraPosition.target.longitude, latLngThreshold)));
case CameraUpdateType.newLatLng:
expect(currentPosition.target.latitude,
wrapMatcher(closeTo(_kTestMapCenter.latitude, latLngThreshold)));
expect(currentPosition.target.longitude,
wrapMatcher(closeTo(_kTestMapCenter.longitude, latLngThreshold)));
case CameraUpdateType.newLatLngBounds:
final LatLngBounds bounds = await controller.getVisibleRegion();
expect(
bounds.northeast.longitude,
wrapMatcher(closeTo(
_kTestCameraBounds.northeast.longitude, latLngThreshold)));
expect(
bounds.southwest.longitude,
wrapMatcher(closeTo(
_kTestCameraBounds.southwest.longitude, latLngThreshold)));
case CameraUpdateType.newLatLngZoom:
expect(currentPosition.target.latitude,
wrapMatcher(closeTo(_kTestMapCenter.latitude, latLngThreshold)));
expect(currentPosition.target.longitude,
wrapMatcher(closeTo(_kTestMapCenter.longitude, latLngThreshold)));
expect(currentPosition.zoom, wrapMatcher(equals(_kTestCameraZoomLevel)));
case CameraUpdateType.scrollBy:
// For scrollBy, just check that the location has changed.
if (oldPosition != null) {
expect(currentPosition.target.latitude,
isNot(equals(oldPosition.target.latitude)));
expect(currentPosition.target.longitude,
isNot(equals(oldPosition.target.longitude)));
}
case CameraUpdateType.zoomBy:
expect(currentPosition.zoom,
wrapMatcher(equals(kInitialZoomLevel + _kTestZoombByAmount)));
case CameraUpdateType.zoomTo:
expect(currentPosition.zoom, wrapMatcher(equals(_kTestCameraZoomLevel)));
case CameraUpdateType.zoomIn:
expect(currentPosition.zoom, wrapMatcher(equals(kInitialZoomLevel + 1)));
case CameraUpdateType.zoomOut:
expect(currentPosition.zoom, wrapMatcher(equals(kInitialZoomLevel - 1)));
}
}
Loading

0 comments on commit 68e52df

Please sign in to comment.