limit scrolling and zooming Google Maps Android API v2

Constraining the camera has (finally!) been added as a feature as part of the release of Google Play Services 9.4 -- you can call setLatLngBoundsForCameraTarget(LatLngBounds bounds) to set the allowed panning area.

// Create a LatLngBounds that includes the city of Adelaide in Australia.
final LatLngBounds ADELAIDE = new LatLngBounds(
    new LatLng(-35.0, 138.58), new LatLng(-34.9, 138.61));

// Constrain the camera target to the Adelaide bounds.
mMap.setLatLngBoundsForCameraTarget(ADELAIDE);

You can find a thorough explanation in the documentation: Restricting the user's panning to a given area and a sample activity in GitHub.


May be it is too late, but here is my solution:

  1. Disabled built-in GoogleMap's gestures.

  2. Added custom gestures (for scrolling, fling and scaling).

  3. Checking for allowed area when processing events.

  4. Set bounds/zoom manually with standard Map's functions.

And here is my example:

[UPDATED]

There was a problem - when touch events were recieved before map was initialized.

check null values in onInterceptTouchEvent

Also I have discovered that my solution is slightly slowly than build-in function.

import android.content.Context;
import android.graphics.Point;
import android.os.Handler;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import com.google.android.gms.common.GooglePlayServicesNotAvailableException;
import com.google.android.gms.maps.CameraUpdate;
import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.GoogleMapOptions;
import com.google.android.gms.maps.MapView;
import com.google.android.gms.maps.MapsInitializer;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.VisibleRegion;

public class RestrictedMapView extends MapView {

    public static float MAX_ZOOM = 20;
    public static float MIN_ZOOM = 5;
    public static float MIN_ZOOM_FOR_FLING = 7;

    public static double MAX_LONGITUDE = 183.61;
    public static double MIN_LONGITUDE = 159.31;
    public static double MAX_LATITUDE = -32.98;
    public static double MIN_LATITUDE = -53.82;

    public static double DEF_LATITUDE = -41.78;
    public static double DEF_LONGITUDE = 173.02;
    public static float DEF_ZOOM = 7;

    private Handler mHandler = new Handler();
    private Context mContext;
    private VisibleRegion mLastCorrectRegion = null;
    private boolean mIsInAnimation = false;

    public RestrictedMapView(Context c, AttributeSet a, int o) {
        super(c, a, o);
        init(c);
    }
    public RestrictedMapView(Context c, AttributeSet a) {
        super(c, a);
        init(c);
    }
    public RestrictedMapView(Context c) {
        super(c);
        init(c);
    }

    public RestrictedMapView(Context c, GoogleMapOptions o) {
        super(c, o);
        init(c);
    }

    private GestureDetector mGestureDetector = null;
    private GestureDetector.SimpleOnGestureListener mGestudeListener =
            new GestureDetector.SimpleOnGestureListener() {

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (mIsInAnimation) return false;
            GoogleMap map = getMap();
            LatLng target = map.getCameraPosition().target;
            Point screenPoint = map.getProjection().toScreenLocation(target);
            Point newPoint = new Point(screenPoint.x + (int)distanceX, screenPoint.y + (int)distanceY);
            LatLng mapNewTarget = map.getProjection().fromScreenLocation(newPoint);
            CameraUpdate update = CameraUpdateFactory.newLatLngZoom(
                    mapNewTarget,map.getCameraPosition().zoom);         
            tryUpdateCamera(update, 0); 
            return true;
        }

        @Override
        public boolean onFling (MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (mIsInAnimation) return false;
            GoogleMap map = getMap();
            double zoom = map.getCameraPosition().zoom;
            if (zoom < MIN_ZOOM_FOR_FLING) 
                return false;
            int velocity = (int) Math.sqrt(velocityX * velocityX + velocityY * velocityY);
            if (velocity < 500) return false;
            double k1 = 0.002d; /*exipemental*/
            double k2 = 0.002d;/*exipemental*/

            LatLng target = map.getCameraPosition().target;
            Point screenPoint = map.getProjection().toScreenLocation(target);
            Point newPoint = new Point(screenPoint.x - (int)(velocityX * k1 * zoom * zoom/*exipemental*/),
                    screenPoint.y - (int)(velocityY * k1 * zoom * zoom/*exipemental*/));
            LatLng mapNewTarget = map.getProjection().fromScreenLocation(newPoint);
            CameraUpdate update = CameraUpdateFactory.newLatLngZoom(
                    mapNewTarget,map.getCameraPosition().zoom); 
            tryUpdateCamera(update, (int)(velocity * k2 * zoom * zoom) /*exipemental*/);    
            return true;
        }
    };  
    private ScaleGestureDetector mScaleGestureDetector = null;
    private ScaleGestureDetector.SimpleOnScaleGestureListener mScaleGestudeListener =
            new ScaleGestureDetector.SimpleOnScaleGestureListener() {

        @Override
        public boolean onScale (ScaleGestureDetector detector) {
            if (mIsInAnimation) return false;

            GoogleMap map = getMap();
            double zoom = map.getCameraPosition().zoom;

            double k = 1d / detector.getScaleFactor();
            int x = (int) detector.getFocusX();
            int y = (int) detector.getFocusY();
            LatLng mapFocus = map.getProjection().
                    fromScreenLocation(new Point(x, y));
            LatLng target = map.getCameraPosition().target;

            zoom = zoom + Math.log(detector.getScaleFactor()) / Math.log(2d);
            if (zoom < MIN_ZOOM)  
                if (zoom == MIN_ZOOM) return false;
                else zoom = MIN_ZOOM;
            if (zoom > MAX_ZOOM) 
                if (zoom == MAX_ZOOM) return false;
                else zoom = MAX_ZOOM;

            double dx = norm(mapFocus.longitude) - norm(target.longitude);
            double dy = mapFocus.latitude - target.latitude;
            double dk = 1d - 1d / k;
            LatLng newTarget = new LatLng(target.latitude - dy * dk, 
                    norm(target.longitude) - dx * dk);

            CameraUpdate update = CameraUpdateFactory.newLatLngZoom(newTarget, (float) zoom);           
            tryUpdateCamera(update, 0);         
            return true;
        }
    };


    private void tryUpdateCamera(CameraUpdate update, int animateTime) {
        GoogleMap map = getMap();
        final VisibleRegion reg = map.getProjection().getVisibleRegion();
        if (animateTime <= 0) {
            map.moveCamera(update);
            checkCurrentRegion(reg);
        } else {
            mIsInAnimation = true;
            map.animateCamera(update, animateTime, new GoogleMap.CancelableCallback() {
                @Override
                public void onFinish() {
                    mIsInAnimation = false;
                    checkCurrentRegion(reg);
                }
                @Override
                public void onCancel() {
                    mIsInAnimation = false;
                    checkCurrentRegion(reg);
                }
            });
        }
    }

    private void checkCurrentRegion(VisibleRegion oldReg) {
        GoogleMap map = getMap();
        VisibleRegion regNew = map.getProjection().getVisibleRegion();
        if (checkBounds(regNew)) {
            mLastCorrectRegion = regNew;
        } else {
            if (mLastCorrectRegion != null)
                oldReg = mLastCorrectRegion;
            mIsInAnimation = true;
            map.animateCamera(CameraUpdateFactory.newLatLngBounds(
                    oldReg.latLngBounds, 0),
                    200, new GoogleMap.CancelableCallback() {
                        @Override
                        public void onFinish() {
                            mIsInAnimation = false;
                        }                       
                        @Override
                        public void onCancel() {
                            mIsInAnimation = false;
                        }
                    });

        }
    }

    /**
     * 
     * 
     * @param lonVal
     * @return
     */
    private double norm(double lonVal) {
        while (lonVal > 360d) lonVal -= 360d;
        while (lonVal < -360d) lonVal += 360d;
        if (lonVal < 0) lonVal = 360d + lonVal;
        return lonVal;
    }

    private double denorm(double lonVal) {
        if (lonVal > 180d) lonVal = -360d + lonVal; 
        return lonVal;
    }

    private boolean checkBounds(VisibleRegion reg) {
        double left = Math.min(
                Math.min(norm(reg.farLeft.longitude), norm(reg.nearLeft.longitude)),
                Math.min(norm(reg.farRight.longitude), norm(reg.nearRight.longitude)));
        double right = Math.max(
                Math.max(norm(reg.farLeft.longitude), norm(reg.nearLeft.longitude)),
                Math.max(norm(reg.farRight.longitude), norm(reg.nearRight.longitude)));
        double top = Math.max( 
                Math.max(reg.farLeft.latitude, reg.nearLeft.latitude),
                Math.max(reg.farRight.latitude, reg.nearRight.latitude));
        double bottom = Math.min( 
                Math.min(reg.farLeft.latitude, reg.nearLeft.latitude),
                Math.min(reg.farRight.latitude, reg.nearRight.latitude));

        boolean limitBounds = left < MIN_LONGITUDE || right > MAX_LONGITUDE ||
                bottom < MIN_LATITUDE || top > MAX_LATITUDE;        
        return !limitBounds;
    }

    private void init(Context c) {
        try {
             MapsInitializer.initialize(c);
         } catch (GooglePlayServicesNotAvailableException e) {
             e.printStackTrace();
         }
        mContext = c;
        mHandler.post(new Runnable() {          
            @Override
            public void run() {
                GoogleMap map = getMap();
                if (map != null) {
                    getMap().getUiSettings().setZoomControlsEnabled(false);
                    map.getUiSettings().setAllGesturesEnabled(false);
                    map.moveCamera(CameraUpdateFactory.newLatLngZoom(
                            new LatLng(DEF_LATITUDE, DEF_LONGITUDE), DEF_ZOOM));
                    mLastCorrectRegion = map.getProjection().getVisibleRegion();
                    mGestureDetector = new GestureDetector(mContext, mGestudeListener);
                    mScaleGestureDetector = new ScaleGestureDetector(mContext, mScaleGestudeListener);
                } else mHandler.post(this);
            }
        });
    }


    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        if (mGestureDetector != null) mGestureDetector.onTouchEvent(event);
        if (mScaleGestureDetector != null) mScaleGestureDetector.onTouchEvent(event);
        return super.onInterceptTouchEvent(event);
    }
}

Definition within my xml-layout of fragment:

<com.package....RestrictedMapView
    android:id="@+id/mapview"
    android:layout_width="match_parent"
    android:layout_height="match_parent" /> 

In xml file it is possible to define custom zoom/location buttons and set click listeners for manual manipulating camera (in this case you have to check MAX_ZOOM and MIN_ZOOM, and check if the current location is within the allowed bounds).


Too bad that google doesn't let us intercept and block the user, i found that MaciejGórski's answer is the one that suits best to my needs.

I wanted to share my solution (Based on his answer) with you.

First i defined the bounds and max/min zoom:

private final LatLngBounds BOUNDS = new LatLngBounds(new LatLng(41.8138,12.3891), new LatLng(41.9667, 12.5938));
private final int MAX_ZOOM = 18;
private final int MIN_ZOOM = 14;

Then i created this little function to test if current camera bounds are outside of max bounds and return the difference in latitude and longitude.

/**
 * Returns the correction for Lat and Lng if camera is trying to get outside of visible map
 * @param cameraBounds Current camera bounds
 * @return Latitude and Longitude corrections to get back into bounds.
 */
private LatLng getLatLngCorrection(LatLngBounds cameraBounds) {
    double latitude=0, longitude=0;
    if(cameraBounds.southwest.latitude < BOUNDS.southwest.latitude) {
        latitude = BOUNDS.southwest.latitude - cameraBounds.southwest.latitude;
    }
    if(cameraBounds.southwest.longitude < BOUNDS.southwest.longitude) {
        longitude = BOUNDS.southwest.longitude - cameraBounds.southwest.longitude;
    }
    if(cameraBounds.northeast.latitude > BOUNDS.northeast.latitude) {
        latitude = BOUNDS.northeast.latitude - cameraBounds.northeast.latitude;
    }
    if(cameraBounds.northeast.longitude > BOUNDS.northeast.longitude) {
        longitude = BOUNDS.northeast.longitude - cameraBounds.northeast.longitude;
    }
    return new LatLng(latitude, longitude);
}

Then the Handler that controls the overscroll (And overzoom) limiting it every 100ms.

/**
 * Bounds the user to the overlay.
 */
private class OverscrollHandler extends Handler {
    @Override
    public void handleMessage(Message msg) {
        CameraPosition position = mMap.getCameraPosition();
        VisibleRegion region = mMap.getProjection().getVisibleRegion();
        float zoom = 0;
        if(position.zoom < MIN_ZOOM) zoom = MIN_ZOOM;
        if(position.zoom > MAX_ZOOM) zoom = MAX_ZOOM;
        LatLng correction = getLatLngCorrection(region.latLngBounds);
        if(zoom != 0 || correction.latitude != 0 || correction.longitude != 0) {
            zoom = (zoom==0)?position.zoom:zoom;
            double lat = position.target.latitude + correction.latitude;
            double lon = position.target.longitude + correction.longitude;
            CameraPosition newPosition = new CameraPosition(new LatLng(lat,lon), zoom, position.tilt, position.bearing);
            CameraUpdate update = CameraUpdateFactory.newCameraPosition(newPosition);
            mMap.moveCamera(update);
        }
        /* Recursively call handler every 100ms */
        sendEmptyMessageDelayed(0,100);
    }
}

This handler must be defined as a field inside current class (I did this in a class that extends SupportMapFragment)

private OverscrollHandler mOverscrollHandler = new OverscrollHandler();

And lastly it must be called for the first time, i did it at the end of onActivityCreated to be sure that map exists.

@Override
public void onActivityCreated(Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    mContext = getActivity();
    mMap = getMap();
    mMap.setMapType(GoogleMap.MAP_TYPE_NONE);
    mMap.addTileOverlay(new TileOverlayOptions().tileProvider(new VexLocalTileProvider(getResources().getAssets())));
    CameraUpdate upd = CameraUpdateFactory.newLatLngZoom(new LatLng(41.87145, 12.52849), 14);
    mMap.moveCamera(upd);
    mOverscrollHandler.sendEmptyMessageDelayed(0,100);
}

Hope you will find it useful!


Instead of using new and shiny push technology which is onCameraChange, you may try to use old poll technology: map.getCameraPosition() or map.getProjection().getVisibleRegion(). You can then check if returned value is what you like and if not, map.moveCamera(...).

Basically you need a Handler, which will get the value of camera position in handleMessage and you send messages to this handler every 10ms or so. Inside handleMessage do sendEmptyMessageDelayed.

You can also use Runnable instead of Message (but that's a matter of taste).


limit zoom you can user this code

private GoogleMap mMap;
// Set a preference for minimum and maximum zoom.
mMap.setMinZoomPreference(6.0f);
mMap.setMaxZoomPreference(14.0f);