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

AndroidAndroid Maps-V2

Android Problem Overview


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?

Android Solutions


Solution 1 - Android

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 - Android

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 - Android

Edit: The below code is for the deprecated v1 maps. This SO answer states how to perform similar actions in v2: https://stackoverflow.com/questions/13739075/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.

Solution 4 - Android

The API's projection stuff isn't all that useful, I ran into the same issue and rolled my own.

public class SphericalMercatorProjection {

	public static PointD latLngToWorldXY(LatLng latLng, double zoomLevel) {
		final double worldWidthPixels = toWorldWidthPixels(zoomLevel);
		final double x = latLng.longitude / 360 + .5;
		final double siny = Math.sin(Math.toRadians(latLng.latitude));
		final double y = 0.5 * Math.log((1 + siny) / (1 - siny)) / -(2 * Math.PI) + .5;
		return new PointD(x * worldWidthPixels, y * worldWidthPixels);
	}

	public static LatLng worldXYToLatLng(PointD point, double zoomLevel) {
		final double worldWidthPixels = toWorldWidthPixels(zoomLevel);
		final double x = point.x / worldWidthPixels - 0.5;
		final double lng = x * 360;
	
		double y = .5 - (point.y / worldWidthPixels);
		final double lat = 90 - Math.toDegrees(Math.atan(Math.exp(-y * 2 * Math.PI)) * 2);
	
		return new LatLng(lat, lng);
	}

	private static double toWorldWidthPixels(double zoomLevel) {
		return 256 * Math.pow(2, zoomLevel);
	}
}

Using this code, you can transform the map around a new target (i.e. not the center). I opted to use an anchor point system, where (0.5, 0.5) is the default and represents the middle of the screen. (0,0) is top left and (1, 1) is bottom left.

private LatLng getOffsetLocation(LatLng location, double zoom) {
	Point size = getSize();
	PointD anchorOffset = new PointD(size.x * (0.5 - anchor.x), size.y * (0.5 - anchor.y));
	PointD screenCenterWorldXY = SphericalMercatorProjection.latLngToWorldXY(location, zoom);
	PointD newScreenCenterWorldXY = new PointD(screenCenterWorldXY.x + anchorOffset.x, screenCenterWorldXY.y + anchorOffset.y);
	newScreenCenterWorldXY.rotate(screenCenterWorldXY, cameraPosition.bearing);
	return SphericalMercatorProjection.worldXYToLatLng(newScreenCenterWorldXY, zoom);
}

Basically, you use the projection to get the world XY co-ord, then offset that point and turn it back into a LatLng. You can then pass this to your map. PointD is simple type which contains x,y as doubles and also does rotation.

public class PointD {
	
	public double x;
	public double y;
	
	public PointD(double x, double y) {
		this.x = x;
		this.y = y;
	}
	
	public void rotate(PointD origin, float angleDeg) {
		double rotationRadians = Math.toRadians(angleDeg);
		this.x -= origin.x;
		this.y -= origin.y;
		double rotatedX = this.x * Math.cos(rotationRadians) - this.y * Math.sin(rotationRadians);
		double rotatedY = this.x * Math.sin(rotationRadians) + this.y * Math.cos(rotationRadians);
		this.x = rotatedX;
		this.y = rotatedY;
		this.x += origin.x;
		this.y += origin.y;
	}
}

Note, if you are updating the map bearing and location at the same time, it is important that you use the new bearing in getOffsetLocation.

Solution 5 - Android

The easy way would be to chain camera animations. First you zoom normally to your picture #2 using animateCamera with 3 parameters and in CancellableCallback.onFinish you do your projection calculations and animate using your code to change center.

I believe (didn't test it) this will look very well if you set some good values for animation duration for both animations. Maybe second animation should be much faster than the first. Also if your transparent, overlaid View is not visible when no marker is selected, a nice UX would be to animate it in from left during the second animation.

The hard way would be to implement your own Spherical Mercator Projection like the one Maps API v2 internally uses. If you only need longitude offset that shouldn't be that hard. If your users can change the bearing and you don't want to reset it to 0 when zooming, you need latitude offset too and this will probably be a bit more complex to implement.

I also think it would be good to have Maps API v2 functionality where you can get projection for any CameraPosition and not only current so you might want to submit a feature request here: http://code.google.com/p/gmaps-api-issues/issues/list?can=2&q=apitype=Android2. You could easily use it in your code for the effect you want.

Solution 6 - Android

For setting a marker, we use Overlays in Android, In overlay class==> on draw method place this thing

boundCenter(marker);

and in constructor also mention like this:

    public xxxxxxOverlay(Drawable marker, ImageView dragImage, MapView map) {
    		super(boundCenter(marker)); 
.
.
.
.
.
    }

Solution 7 - Android

I have almost exactly the same problem as you (only my offset is vertical), and I've tried a solution that didn't work as expected, but could help you (us) to find an better solution.

What I did was to move the camera of the map to get the projection on target, and apply the offset there.

I placed the code on a Utils class and it looks like this:

public static CameraBuilder moveUsingProjection(GoogleMap map, CameraBuilder target, int verticalMapOffset) {
	CameraPosition oldPosition = map.getCameraPosition();
	map.moveCamera(target.build(map));
	CameraBuilder result = CameraBuilder.builder()
			.target(moveUsingProjection(map.getProjection(), map.getCameraPosition().target, verticalMapOffset))
			.zoom(map.getCameraPosition().zoom).tilt(map.getCameraPosition().tilt);
	map.moveCamera(CameraUpdateFactory.newCameraPosition(oldPosition));
	return result;
}

public static LatLng moveUsingProjection(Projection projection, LatLng latLng, int verticalMapOffset) {
	Point screenPoint = projection.toScreenLocation(latLng);
	screenPoint.y -= verticalMapOffset;
	return projection.fromScreenLocation(screenPoint);
}

Then you could use the CameraBuilder returned by these methods for the animation, instead of the original (not offseted) target.

It works (more or less), but it has two big issues:

  • The map flickers sometimes (this is the biggest issue, because it makes the hack unusable)
  • It is a hack that may work today and may not work tomorrow (I know for a fact that this same algorithm works on iOS, for example).

So I didn't go for this, but it got me thinking of an alternative: If you had an identical map but hidden, you could use that map for intermediate calculations, and then use the final result on the visible map.

I think this alternative would work, but it is horrible, since you will need to mantain two identical maps, with the overhead that comes with it, for you and for the device.

As I said, I don't have a definitive solution for this, but maybe this helps a little.

I hope this helps!

Solution 8 - Android

I have resolved the issue by calculating a distance vector using the LatLng values of two fixed points on the map, then creating another LatLng using the Marker position, this vector and a pre-defined ratio value. There are a lot of objects involved in this one, however it doesn't depend on the current zoom level and rotation of the map, and might just work as great for you!

// Just a helper function to obtain the device's display size in px
Point displaySize = UIUtil.getDisplaySize(getActivity().getWindowManager().getDefaultDisplay());

// The two fixed points: Bottom end of the map & Top end of the map,
// both centered horizontally (because I want to offset the camera vertically...
// if you want to offset it to the right, for instance, use the left & right ends)
Point up = new Point(displaySize.x / 2, 0);
Point down = new Point(displaySize.x / 2, displaySize.y);

// Convert to LatLng using the GoogleMap object
LatLng latlngUp = googleMap.getProjection().fromScreenLocation(up);
LatLng latlngDown = googleMap.getProjection().fromScreenLocation(down);

// Create a vector between the "screen point LatLng" objects
double[] vector = new double[] {latlngUp.latitude - latlngDown.latitude,
                                latlngUp.longitude - latlngDown.longitude};

// Calculate the offset position
LatLng latlngMarker = marker.getPosition();
double offsetRatio = 1.0/2.5;
LatLng latlngOffset = new LatLng(latlngMarker.latitude + (offsetRatio * vector[0]),
                                 latlngMarker.longitude + (offsetRatio * vector[1]));

// Move the camera to the desired position
CameraUpdate cameraUpdate = CameraUpdateFactory.newLatLng(latlngOffset);
googleMap.animateCamera(cameraUpdate);

Solution 9 - Android

I was dealing with the same task and ended with the similar solution as your last option :

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

But instead of dividing point's offset on 2<<zoomDifference I'm calculating offset as differences between longitude and latitude values. They are doubles so it gives me good enough accuracy. So try to convert your offsets to lat/lng not your location to pixels and the just do the same calculation.


Edited

Finally I've managed to solve it in another way (most appropriate one as I believe). Map API v2 has padding parameters. Just setup it to make camera to take all not hidden part of MapView. Then camera would reflect the center of the padded region after animateCamera()

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionDan LewView Question on Stackoverflow
Solution 1 - AndroidKyle IveyView Answer on Stackoverflow
Solution 2 - AndroidsantilodView Answer on Stackoverflow
Solution 3 - Androidmunch1324View Answer on Stackoverflow
Solution 4 - AndroidMichael FryView Answer on Stackoverflow
Solution 5 - AndroidMaciejGórskiView Answer on Stackoverflow
Solution 6 - AndroidVINIL SATRASALAView Answer on Stackoverflow
Solution 7 - AndroidpablitarView Answer on Stackoverflow
Solution 8 - AndroidMarcel SchnelleView Answer on Stackoverflow
Solution 9 - AndroidamukhachovView Answer on Stackoverflow