Offseting the center of the MapFragment for an animation moving both the target lat/lng and the zoom level

I've got a UI that has a MapFragment with a transparent View overlaid on top of it. The map takes up the entire screen, whereas the View is just the left third of the screen. As a result, the default "center" of the map is off. When someone clicks on a marker, I want to center that marker in the wholly visible area of the MapFragment (not the center of the MapFragment itself).

Since this is hard to describe with words, let me use a few pictures. Suppose this is how my UI looks:

Default Map

When the user clicks a marker, I want to both center it and zoom in to see it closer. Without any adjustments, this is what you'll get:

Map with incorrectly centered marker

What I want is for the marker to be centered, but in the space to the right, like this:

Map with correctly centered marker

It's very easy to achieve this feat if you're not changing the zoom level using the map's projection:

// Offset the target latitude/longitude by preset x/y ints
LatLng target = new LatLng(latitude, longitude);
Projection projection = getMap().getProjection();
Point screenLocation = projection.toScreenLocation(target);
screenLocation.x += offsetX;
screenLocation.y += offsetY;
LatLng offsetTarget = projection.fromScreenLocation(screenLocation);

// Animate to the calculated lat/lng
getMap().animateCamera(CameraUpdateFactory.newLatLng(offsetTarget));

However, if you're changing the zoom level at the same time, the above calculations don't work (since the lat/lng offsets change at different zoom levels).

Let me run down a list of attempted fixes:

  • Changing the zoom level quickly, doing the calculations, zooming back to the original camera position, then animating. Unfortunately the sudden camera change (even if it's only for a split second) is unfortunately very obvious and I'd like to avoid the flicker.

  • Overlaying two MapFragments on top of each other, having one do the calculations while the other displays. I've found that MapFragments are not really built to be layered on top of each other (there are unavoidable bugs down this route).

  • Modifying the screen location's x/y by the difference in zoom level squared. Theoretically this should work but it's always off by quite a bit (~.1 latitude/longitude, which is enough to be way off).

Is there a way to calculate the offsetTarget even with the zoom level changing?


Solution 1:

The most recent version of the play services library adds a setPadding() function to GoogleMap. This padding will project all camera movements to the offset map center. This doc further explains how map padding behaves.

The question originally asked here can now simply be solved by adding left padding equivalent to the width of the left-side layout.

mMap.setPadding(leftPx, topPx, rightPx, bottomPx);

Solution 2:

I found a solution that really fits to the problem. The solution is to calculate the offset's center in the required zoom from original's offset, and then animate the map's camera. For this, first move the map's camera to the desired zoom, calculate the offset for that zoom level, and then restore the original zoom. After calculating the new center we can make the animation with CameraUpdateFactory.newLatLngZoom.

I hope this helps!

private void animateLatLngZoom(LatLng latlng, int reqZoom, int offsetX, int offsetY) {

    // Save current zoom
    float originalZoom = mMap.getCameraPosition().zoom;

    // Move temporarily camera zoom
    mMap.moveCamera(CameraUpdateFactory.zoomTo(reqZoom));

    Point pointInScreen = mMap.getProjection().toScreenLocation(latlng);

    Point newPoint = new Point();
    newPoint.x = pointInScreen.x + offsetX;
    newPoint.y = pointInScreen.y + offsetY;

    LatLng newCenterLatLng = mMap.getProjection().fromScreenLocation(newPoint);

    // Restore original zoom
    mMap.moveCamera(CameraUpdateFactory.zoomTo(originalZoom));

    // Animate a camera with new latlng center and required zoom.
    mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(newCenterLatLng, reqZoom));

}

Solution 3:

Edit: The below code is for the deprecated v1 maps. This SO answer states how to perform similar actions in v2: How to get Latitude/Longitude span in Google Map V2 for Android

The key methods you need should work in long/lat and percentages instead of pixels. Right now, the mapview will zoom around the center of the map, and then move to 'recenter' the pin in the exposed area. You can get the width after the zoom in degrees, factor in some screen bias, and then recenter the map. Here is a sample screenshot of the below code in action after pressing the '+': before enter image description here afterenter image description here Main code:

@Override
            public void onZoom(boolean zoomIn) {
                // TODO Auto-generated method stub
                controller.setZoom(zoomIn ? map.getZoomLevel()+1 : map.getZoomLevel()-1);
                int bias = (int) (map.getLatitudeSpan()* 1.0/3.0); // a fraction of your choosing
                map.getLongitudeSpan();
                controller.animateTo(new GeoPoint(yourLocation.getLatitudeE6(),yourLocation.getLongitudeE6()-bias));

            }

All code:

    package com.example.testmapview;

import com.google.android.maps.GeoPoint;
import com.google.android.maps.ItemizedOverlay;
import com.google.android.maps.MapActivity;
import com.google.android.maps.MapController;
import com.google.android.maps.MapView;

import android.os.Bundle;
import android.app.Activity;
import android.graphics.drawable.Drawable;
import android.view.Menu;
import android.widget.ZoomButtonsController;
import android.widget.ZoomButtonsController.OnZoomListener;

public class MainActivity extends MapActivity {
    MapView map;
    GeoPoint yourLocation;
    MapController controller;
    ZoomButtonsController zoomButtons;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        map=(MapView)findViewById(R.id.mapview);
        map.setBuiltInZoomControls(true);
        controller = map.getController();
        zoomButtons = map.getZoomButtonsController();

        zoomButtons.setOnZoomListener(new OnZoomListener(){

            @Override
            public void onVisibilityChanged(boolean arg0) {
                // TODO Auto-generated method stub

            }

            @Override
            public void onZoom(boolean zoomIn) {
                // TODO Auto-generated method stub
                controller.setZoom(zoomIn ? map.getZoomLevel()+1 : map.getZoomLevel()-1);
                int bias = (int) (map.getLatitudeSpan()* 1.0/3.0); // a fraction of your choosing
                map.getLongitudeSpan();
                controller.animateTo(new GeoPoint(yourLocation.getLatitudeE6(),yourLocation.getLongitudeE6()-bias));

            }
        });

        //Dropping a pin, setting center
        Drawable marker=getResources().getDrawable(android.R.drawable.star_big_on);
        MyItemizedOverlay myItemizedOverlay = new MyItemizedOverlay(marker);
        map.getOverlays().add(myItemizedOverlay);
        yourLocation = new GeoPoint(0, 0);
        controller.setCenter(yourLocation);
        myItemizedOverlay.addItem(yourLocation, "myPoint1", "myPoint1");
        controller.setZoom(5);

    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.activity_main, menu);
        return true;
    }

    @Override
    protected boolean isRouteDisplayed() {
        // TODO Auto-generated method stub
        return false;
    }

}

and a basic layout:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.testmapview"
    android:versionCode="1"
    android:versionName="1.0" >
<meta-data
    android:name="com.google.android.maps.v2.API_KEY"
    android:value="YOURKEYHERE:)"/>
    <uses-sdk
        android:minSdkVersion="8"
        android:targetSdkVersion="16" />

<uses-permission android:name="android.permission.INTERNET" /> 
    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <uses-library android:name="com.google.android.maps"/>

        <activity
            android:name="com.example.testmapview.MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

If this doesn't fully answer your question, please let me know as this was a fun experiment to play around with.