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

9

52

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?

Donahoe answered 25/3, 2013 at 22:23 Comment(8)
"since the lat/lng required changes at different zoom levels" -- that doesn't really make a lot of sense to me. Beyond that, I'm not quite certain why you're going through all the screen coordinate stuff. "The left third" is "the left third" whether you measure it in pixels, degrees, millimeters, parsecs, or whatever. Would keeping all your calculations in terms of latitude and longitude help?Aporia
I figured this would be difficult to explain with just words; let me try to draw up a picture that illuminates the problem.Donahoe
There, same question but with visual aids. The problem with using all latitude/longitude for calculations is that the offset I desire is measured purely in pixels (based on screen size and how much of the MapFragment is visible).Donahoe
"the offset I desire is measured purely in pixels" -- so when you wrote "the View is just the left third of the screen", that wasn't an accurate statement? Because, if it is accurate, pixels are irrelevant, as a third is a third is a third regardless of units of measure.Aporia
The pixels that constitue 1/3rd of the screen changes based on screen size. All camera changes require a target LatLng, so I must be able to convert everything to/from LatLng. Essentially I'm trying to convert pixels on the screen to an offset latitude/longitude. That lat/lng offset changes based on the zoom level, since the more zoomed in you are, the more degrees change you need to move around the map.Donahoe
In addition, I am simplifying the actual problem a bit for the sake of brevity. The View on the left is not 1/3rd, it's actually a set width that varies based on the min-width of the tablet. Additionally, I also want to offset it vertically (since I want to display some interactive information above the marker as well, as a popup).Donahoe
Ah! OK, now I understand why you need pixels. Unfortunately, I don't have a solution for you. :-(Aporia
you solved ? I have the same problem!Billposter
L
41

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);
Lordsandladies answered 19/12, 2013 at 1:36 Comment(2)
This is the way to go.Waac
This works for the most part, but note that tap-and-drag to zoom is broken in Play Lib - it will continue to use a pivot at the center of the MapView, not the center of the padded area. This creates a bad user experience.Tirrell
B
19

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

}
Balcke answered 19/11, 2014 at 17:3 Comment(3)
As for today, this should be the excepted answer!Pentahedron
This is the simplest yet most effective way to achieve the desired effect.Anabantid
You saved my ass my friend :)Undecagon
J
11

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.

Javier answered 29/3, 2013 at 21:4 Comment(7)
You are using deprecated API v1.Lynden
Thanks for pointing that out, I think the implementation should almost be identical since v2 also has a similar API to v1. I edited my answer to note thisJavier
V2 has new API which is in no way based on V1.Lynden
And also, the point is to get the correct lat/lng/zoom on one try, not to animate twice.Donahoe
as a hack around, you could have a separate mapview under your top mapview. Zoom in the hidden mapview, get the center using the calculations above, and then do a single animation combining zoom/recenter on the visible mapview (not ideal).Javier
My question already mentions the idea of having two MapFragments overlaid on top of each other. And it mentions that this is for maps v2, not v1.Donahoe
As for your edit - the linked question has almost nothing to do with my question. I am not sure who is upvoting this but it's not going to get accepted as the bounty answer.Donahoe
B
5

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.

Bradbradan answered 20/5, 2014 at 20:51 Comment(0)
M
2

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.

Microbarograph answered 3/4, 2013 at 18:13 Comment(3)
I've chained camera animations (as another possible solution). The result is not very pretty.Donahoe
Can you share animations durations? Do you use delay after first anim ends before starting second? There is always the hard way. I use Spherical Mercator in grid clustering algorithm. The use is quite simple: calculating latitudes that create square for given longitude span at any zoom level: SphericalMercator.javaLynden
The point is to have a pretty animation from CameraPosition A to CameraPosition B. It doesn't really matter the durations - if you have an interstitial CameraPosition it just looks funny (speaking from the experience of having tried this solution already).Donahoe
D
0

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)); 
.
.
.
.
.
    }
Despairing answered 4/4, 2013 at 6:36 Comment(0)
S
0

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!

Spittle answered 29/5, 2013 at 19:29 Comment(1)
what on Earth is Camera Builder ?Stakeout
W
0

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);
Warr answered 13/9, 2013 at 10:59 Comment(0)
I
0

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

Inguinal answered 5/12, 2014 at 13:27 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.