Search code examples
javaandroidreact-nativegoogle-maps-android-api-2react-native-component

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


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) z=13.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/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.

    enter image description here