Google Maps SDK for Android: Smoothly animating the camera to a new location, rendering all the tiles along the way

Issue

Background

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.

Code

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

Question

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)!

Update 30/09/2021

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?

Solution

Here’s my attempt using your utility frame player.

A few notes:

  • The zoom value is interpolated based on the total steps (set at 500 here) and given the start and stop values.
  • A Google Maps utility is used to compute the next lat lng based on a fractional distance: SphericalUtil.interpolate.
  • The fractional distance should not be a linear function to reduce the introduction of new tiles. In other words, at higher zooms (closer in) the camera moves in shorter distances and the amount of camera movement increases exponentially (center-to-center) while zooming out. This requires a bit more explanation…
  • As you can see the traversal is split into two – reversing the exponential function of the distance movement.
  • The “max” zoom (bad name) which is the furthest out can be a function of the total distance – computed to encompass the whole path at the midpoint. For now it’s hard coded to 4 for this case.
  • Note the maps 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.
  • This method attempts to minimize tile loading per step but ultimately the TileLoader is the limiting factor for viewing which cannot monitored (easily).

animateCameraToPosition

// 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();
}

Test Code

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

CameraUpdateAnimator Mods

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

Before Sample

Using

// assume initial (40.68924, -74.04454) z13.0f
gMap.animateCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(33.899832,-118.020450), 13.0f), 30000, null);

After Samples

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

500 Steps 0 delay

100 Steps 0 delay

50 Steps 100MS delay


Diagnostics

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/lyrsm&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 
x2411 y3080 zoom13
x2410 y3080 zoom13
x2411 y3081 zoom13
x2410 y3081 zoom13
x2411 y3079 zoom13
x2410 y3079 zoom13

And at max:

x9 y12 zoom5
x8 y12 zoom5
x9 y11 zoom5
x8 y11 zoom5
x8 y13 zoom5
x9 y13 zoom5
x7 y12 zoom5
x7 y11 zoom5
x7 y13 zoom5
x8 y10 zoom5
x9 y10 zoom5
x7 y10 zoom5

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.

enter image description here

Answered By – Andy

Leave a Comment