Many similar questions seem to have been asked on SO before (most notably android google maps not loading the map when using GoogleMap.AnimateCamera() and How can I smoothly pan a GoogleMap in Android?), but none of the answers or comments posted throughout those threads have given me a firm idea of how to do this.
I initially thought that it would be as simple as just calling animateCamera(CameraUpdateFactory.newLatLng(), duration, callback)
but like the OP of the first link above, all I get is a gray or very blurry map until the animation completes, even if I slow it down to tens of seconds long!
I've managed to find and implement this helper class that does a nice job of allowing the tiles to render along the way, but even with a delay of 0, there is a noticeable lag between each animation.
OK, time for some code. Here's the (slightly-modified) helper class:
package com.coopmeisterfresh.googlemaps.NativeModules;
import android.os.Handler;
import com.google.android.gms.maps.CameraUpdate;
import com.google.android.gms.maps.GoogleMap;
import java.util.ArrayList;
import java.util.List;
public class CameraUpdateAnimator implements GoogleMap.OnCameraIdleListener {
private final GoogleMap mMap;
private final GoogleMap.OnCameraIdleListener mOnCameraIdleListener;
private final List<Animation> cameraUpdates = new ArrayList<>();
public CameraUpdateAnimator(GoogleMap map, GoogleMap.
OnCameraIdleListener onCameraIdleListener) {
mMap = map;
mOnCameraIdleListener = onCameraIdleListener;
}
public void add(CameraUpdate cameraUpdate, boolean animate, long delay) {
if (cameraUpdate != null) {
cameraUpdates.add(new Animation(cameraUpdate, animate, delay));
}
}
public void clear() {
cameraUpdates.clear();
}
public void execute() {
mMap.setOnCameraIdleListener(this);
executeNext();
}
private void executeNext() {
if (cameraUpdates.isEmpty()) {
mOnCameraIdleListener.onCameraIdle();
} else {
final Animation animation = cameraUpdates.remove(0);
new Handler().postDelayed(() -> {
if (animation.mAnimate) {
mMap.animateCamera(animation.mCameraUpdate);
} else {
mMap.moveCamera(animation.mCameraUpdate);
}
}, animation.mDelay);
}
}
@Override
public void onCameraIdle() {
executeNext();
}
private static class Animation {
private final CameraUpdate mCameraUpdate;
private final boolean mAnimate;
private final long mDelay;
public Animation(CameraUpdate cameraUpdate, boolean animate, long delay) {
mCameraUpdate = cameraUpdate;
mAnimate = animate;
mDelay = delay;
}
}
}
And my code to implement it:
// This is actually a React Native Component class, but I doubt that should matter...?
public class NativeGoogleMap extends SimpleViewManager<MapView> implements
OnMapReadyCallback, OnRequestPermissionsResultCallback {
// ...Other unrelated methods removed for brevity
private void animateCameraToPosition(LatLng targetLatLng, float targetZoom) {
// googleMap is my GoogleMap instance variable; it
// gets properly initialised in another class method
CameraPosition currPosition = googleMap.getCameraPosition();
LatLng currLatLng = currPosition.target;
float currZoom = currPosition.zoom;
double latDelta = targetLatLng.latitude - currLatLng.latitude;
double lngDelta = targetLatLng.longitude - currLatLng.longitude;
double latInc = latDelta / 5;
double lngInc = lngDelta / 5;
float zoomInc = 0;
float minZoom = googleMap.getMinZoomLevel();
float maxZoom = googleMap.getMaxZoomLevel();
if (lngInc > 15 && currZoom > minZoom) {
zoomInc = (minZoom - currZoom) / 5;
}
CameraUpdateAnimator animator = new CameraUpdateAnimator(googleMap,
() -> googleMap.animateCamera(CameraUpdateFactory.zoomTo(
targetZoom), 5000, null));
for (double nextLat = currLatLng.latitude, nextLng = currLatLng.
longitude, nextZoom = currZoom; Math.abs(nextLng) < Math.abs(
targetLatLng.longitude);) {
nextLat += latInc;
nextLng += lngInc;
nextZoom += zoomInc;
animator.add(CameraUpdateFactory.newLatLngZoom(new
LatLng(nextLat, nextLng), (float)nextZoom), true);
}
animator.execute();
}
}
Is there a better way to accomplish this seemingly-simple task? I'm thinking that perhaps I need to move my animations to a worker thread or something; would that help?
Thanks for reading (I know it was an effort :P)!
I've updated the code above in line with Andy's suggestions in the comments and although it works (albeit with the same lag and rendering issues), the final algorithm will need to be a bit more complex since I want to zoom out to the longitudinal delta's half-way point, then back in as the journey continues.
Doing all these calculations at once, as well as smoothly rendering all the necessary tiles simultaneously, seems to be way too much for the cheap mobile phone that I'm testing on. Or is this a limitation of the API itself? In any case, how can I get all of this working smoothly, without any lag whatsoever between queued animations?
Here's my attempt using your utility frame player.
A few notes:
SphericalUtil.interpolate
.animate
function cannot be used as it introduces its own bouncing ball effect on each step which is undesirable. So given a fair number of steps the move
function can be used.// flag to control the animate callback (at completion).
boolean done = false;
private void animateCameraToPosition(LatLng targetLatLng, float targetZoom) {
CameraPosition currPosition = gMap.getCameraPosition();
LatLng currLatLng = currPosition.target;
//meters_per_pixel = 156543.03392 * Math.cos(latLng.lat() * Math.PI / 180) / Math.pow(2, zoom)
int maxSteps = 500;
// number of steps between start and midpoint and midpoint and end
int stepsMid = maxSteps / 2;
// current zoom
float initz = currPosition.zoom;
//TODO maximum zoom (can be computed from overall distance) such that entire path
// is visible at midpoint.
float maxz = 4.0f;
float finalz = targetZoom;
CameraUpdateAnimator animator = new CameraUpdateAnimator(gMap, () -> {
if (!done) {
gMap.animateCamera(CameraUpdateFactory.
zoomTo(targetZoom), 5000, null);
}
done = true;
});
// loop from start to midpoint
for (int i = 0; i < stepsMid; i++) {
// compute interpolated zoom (current --> max) (linear)
float z = initz - ((initz - maxz) / stepsMid) * i;
// Compute fractional distance using an exponential function such that for the first
// half the fraction delta advances slowly and accelerates toward midpoint.
double ff = (i * (Math.pow(2,maxz) / Math.pow(2,z))) / maxSteps;
LatLng nextLatLng =
SphericalUtil.interpolate(currLatLng, targetLatLng, ff);
animator.add(CameraUpdateFactory.newLatLngZoom(
nextLatLng, z), false, 0);
}
// loop from midpoint to final
for (int i = 0; i < stepsMid; i++) {
// compute interpolated zoom (current --> max) (linear)
float z = maxz + ((finalz - maxz) / stepsMid) * i;
double ff = (maxSteps - ((i+stepsMid) * ( (Math.pow(2,maxz) / Math.pow(2,z)) ))) / (double)maxSteps;
LatLng nextLatLng =
SphericalUtil.interpolate(currLatLng, targetLatLng, ff);
animator.add(CameraUpdateFactory.newLatLngZoom(
nextLatLng, z), false, 0);
}
animator.add(CameraUpdateFactory.newLatLngZoom(
targetLatLng, targetZoom), true, 0);
//
animator.execute();
}
I tested with these two points (and code) from Statue Of Liberty to a point on the west coast:
gMap.moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(40.68924, -74.04454), 13.0f));
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
animateCameraToPosition(new LatLng(33.899832, -118.020450), 13.0f);
}
}, 5000);
I modified the camera update animator slightly:
public void execute() {
mMap.setOnCameraIdleListener(this);
executeNext();
}
private void executeNext() {
if (cameraUpdates.isEmpty()) {
mMap.setOnCameraIdleListener(mOnCameraIdleListener);
mOnCameraIdleListener.onCameraIdle();
} else {
final Animation animation = cameraUpdates.remove(0);
// This optimization is likely unnecessary since I think the
// postDelayed does the same on a delay of 0 - execute immediately.
if (animation.mDelay > 0) {
new Handler().postDelayed(() -> {
if (animation.mAnimate) {
mMap.animateCamera(animation.mCameraUpdate);
} else {
mMap.moveCamera(animation.mCameraUpdate);
}
}, animation.mDelay);
} else {
if (animation.mAnimate) {
mMap.animateCamera(animation.mCameraUpdate);
} else {
mMap.moveCamera(animation.mCameraUpdate);
}
}
}
}
Using
// assume initial (40.68924, -74.04454) z=13.0f
gMap.animateCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(33.899832,-118.020450), 13.0f), 30000, null);
These are recorded from an emulator. I also sideloaded onto my phone (Samsumg SM-G960U) with similar results (using 1000 steps 0 delay).
So I don't think this meets your requirements entirely: there are some "ambiguous tiles" as they are brought in from the west.
Statue of Liberty - to - somewhere near San Diego
It is in some ways useful to have insight into what Maps is doing with tiles. Insight can be provided by installing a simple UrlTileProvider
and log the requests. This implementation fetches the google tiles though they are lower resolution that is normally seen.
To do this the following is required:
// Turn off this base map and install diagnostic tile provider
gMap.setMapType(GoogleMap.MAP_TYPE_NONE);
gMap.addTileOverlay(new TileOverlayOptions().tileProvider(new MyTileProvider(256,256)).fadeIn(true));
And define the diagnostic file provider
public class MyTileProvider extends UrlTileProvider {
public MyTileProvider(int i, int i1) {
super(i, i1);
}
@Override
public URL getTileUrl(int x, int y, int zoom) {
Log.i("tiles","x="+x+" y="+y+" zoom="+zoom);
try {
return new URL("http://mt1.google.com/vt/lyrs=m&x="+x+"&y="+y+"&z="+zoom);
} catch (MalformedURLException e) {
e.printStackTrace();
return null;
}
}
}
You'll notice right away that tile layers are always defined in integral units (int
). The fractional zooms which are supplied in the zoom (e.g. LatLngZoom
work strictly with the in-memory images - good to know.'
Here's a sample for completeness:
// initial zoom
x=2411 y=3080 zoom=13
x=2410 y=3080 zoom=13
x=2411 y=3081 zoom=13
x=2410 y=3081 zoom=13
x=2411 y=3079 zoom=13
x=2410 y=3079 zoom=13
And at max:
x=9 y=12 zoom=5
x=8 y=12 zoom=5
x=9 y=11 zoom=5
x=8 y=11 zoom=5
x=8 y=13 zoom=5
x=9 y=13 zoom=5
x=7 y=12 zoom=5
x=7 y=11 zoom=5
x=7 y=13 zoom=5
x=8 y=10 zoom=5
x=9 y=10 zoom=5
x=7 y=10 zoom=5
Here's a chart of the zooms (y-axis) at each invocation of tiler (x-axis). Each zoom layer are roughly the same count which imo is what is desired. The full-out zoom appears twice as long because that's the midpoint repeating. There are a few anomalies though which require explaining (e.g. at around 110).
This is a chart of "zoom" as logged by the tile provider. So each x-axis point would represent a single tile fetch.