Keep map centered regardless of where you pinch zoom on android
Asked Answered
S

6

14

I'm looking to do something similar to the way Uber handles pinch zoom events. No matter where you pinch on the screen, it keeps the map centered and zooms in on the center location. Is there a way to do this without having some sort of overlay over the map fragment? Or should I just disable maps' events, create an overlay over the map fragment, and handle all zoom / other events from the overlay?

Scissure answered 30/3, 2015 at 17:3 Comment(1)
Please star this Feature Request to let Google know that we need it: issuetracker.google.com/issues/69795937Agrobiology
B
27

I've founded complete solution after spending about 3 days to search on google. My answer is edited from https://mcmap.net/q/89305/-android-googlemaps-v2-disable-scroll-on-pan-or-zoom.

public class CustomMapView extends MapView {

    private int fingers = 0;
    private GoogleMap googleMap;
    private long lastZoomTime = 0;
    private float lastSpan = -1;
    private Handler handler = new Handler();

    private ScaleGestureDetector scaleGestureDetector;
    private GestureDetector gestureDetector;

    public CustomMapView(Context context) {
        super(context);
    }

    public CustomMapView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CustomMapView(Context context, AttributeSet attrs, int style) {
        super(context, attrs, style);
    }

    public CustomMapView(Context context, GoogleMapOptions options) {
        super(context, options);
    }

    public void init(GoogleMap map) {
        scaleGestureDetector = new ScaleGestureDetector(getContext(), new ScaleGestureDetector.OnScaleGestureListener() {
            @Override
            public boolean onScale(ScaleGestureDetector detector) {
                if (lastSpan == -1) {
                    lastSpan = detector.getCurrentSpan();
                } else if (detector.getEventTime() - lastZoomTime >= 50) {
                    lastZoomTime = detector.getEventTime();
                    googleMap.animateCamera(CameraUpdateFactory.zoomBy(getZoomValue(detector.getCurrentSpan(), lastSpan)), 50, null);
                    lastSpan = detector.getCurrentSpan();
                }
                return false;
            }

            @Override
            public boolean onScaleBegin(ScaleGestureDetector detector) {
                lastSpan = -1;
                return true;
            }

            @Override
            public void onScaleEnd(ScaleGestureDetector detector) {
                lastSpan = -1;

            }
        });
        gestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
            @Override
            public boolean onDoubleTapEvent(MotionEvent e) {

                disableScrolling();
                googleMap.animateCamera(CameraUpdateFactory.zoomIn(), 400, null);

                return true;
            }
        });
        googleMap = map;
    }

    private float getZoomValue(float currentSpan, float lastSpan) {
        double value = (Math.log(currentSpan / lastSpan) / Math.log(1.55d));
        return (float) value;
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        gestureDetector.onTouchEvent(ev);
        switch (ev.getAction() & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_POINTER_DOWN:
                fingers = fingers + 1;
                break;
            case MotionEvent.ACTION_POINTER_UP:
                fingers = fingers - 1;
                break;
            case MotionEvent.ACTION_UP:
                fingers = 0;
                break;
            case MotionEvent.ACTION_DOWN:
                fingers = 1;
                break;
        }
        if (fingers > 1) {
            disableScrolling();
        } else if (fingers < 1) {
            enableScrolling();
        }
        if (fingers > 1) {
            return scaleGestureDetector.onTouchEvent(ev);
        } else {
            return super.dispatchTouchEvent(ev);
        }
    }

    private void enableScrolling() {
        if (googleMap != null && !googleMap.getUiSettings().isScrollGesturesEnabled()) {
            handler.postDelayed(new Runnable() {
                @Override
                public void run() {
                    googleMap.getUiSettings().setAllGesturesEnabled(true);
                }
            }, 50);
        }
    }

    private void disableScrolling() {
        handler.removeCallbacksAndMessages(null);
        if (googleMap != null && googleMap.getUiSettings().isScrollGesturesEnabled()) {
            googleMap.getUiSettings().setAllGesturesEnabled(false);
        }
    }
}

and customize MapFragment

public class CustomMapFragment extends Fragment {

        CustomMapView view;
        Bundle bundle;
        GoogleMap map;

        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            bundle = savedInstanceState;
        }

        @Override
        public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
            View v = inflater.inflate(R.layout.fragment_map, container, false);

            view = (CustomMapView) v.findViewById(R.id.mapView);
            view.onCreate(bundle);
            view.onResume();

            map = view.getMap();
            view.init(map);

            MapsInitializer.initialize(getActivity());

            return v;
        }

        public GoogleMap getMap() {
            return map;
        }

        @Override
        public void onResume() {
            super.onResume();
            view.onResume();
        }

        @Override
        public void onPause() {
            super.onPause();
            view.onPause();
        }

        @Override
        public void onDestroy() {
            super.onDestroy();
            view.onDestroy();
        }

        @Override
        public void onLowMemory() {
            super.onLowMemory();
            view.onLowMemory();
        }
    }

Finally, in your activity:

....
<fragment
    android:id="@+id/map"
    class="yourpackage.CustomMapFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>
...

I've already tested on Android 4.1 (API 16) and latter, it work fine and smooth. (About API < 16, I haven't any device to test).

Bluestone answered 22/10, 2015 at 8:18 Comment(6)
What is a sense of Math.log(1.55d)? And of all the zoom calculation (Math.log(currentSpan / lastSpan) / Math.log(1.55d))?Holily
I used this .. but it has problem with the "double tap hold slide up or down" zoom.Tudela
in CustomMapFragment in dispatchTouchEvent first line must be otherwise to handle corner cases(like if play service version is less then used one update button will not work without this code). if(gestureDetector==null) { return super.dispatchTouchEvent(ev); }Inverson
it still have problem when double tap to zoomMeshwork
what you did in layout named fragment_map can you put it hereDeepsix
Did you find any solutions to make the double tap hold works with this solution ?Bethsaida
S
6

Here's the code for what MechEthan is thinking of.

  1. First you have to detect double-tap on an overlay view.

    public class TouchableWrapper extends FrameLayout {
        private final GestureDetector.SimpleOnGestureListener mGestureListener
                = new GestureDetector.SimpleOnGestureListener() {
            @Override
            public boolean onDoubleTap(MotionEvent e) {
    
                //Notify the event bus (I am using Otto eventbus of course) that you have just received a double-tap event on the map, inside the event bus event listener
                EventBus_Singleton.getInstance().post(new EventBus_Poster("double_tapped_map"));
    
                return true;
            }
        };
    
        public TouchableWrapper(Context context) {
            super(context);
            mGestureDetector = new GestureDetectorCompat(context, mGestureListener);
        }
    
        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            mGestureDetector.onTouchEvent(ev);
    
            return super.onInterceptTouchEvent(ev);
        }
    }
    
  2. Wherever it is that you are grabbing your mapView, wrap that mapView inside the TouchableWrapper created above. This is how I do it because I have the issue of needing to add a mapFragment into another fragment so I need a custom SupportMapFragment to do this

    public class CustomMap_Fragment extends SupportMapFragment {
    
        TouchableWrapper mTouchView;
    
        public CustomMap_Fragment() {
            super();
        }
    
        public static CustomMap_Fragment newInstance() {
            return new CustomMap_Fragment();
        }
    
        @Override
        public View onCreateView(LayoutInflater arg0, ViewGroup arg1, Bundle arg2) {
            View mapView = super.onCreateView(arg0, arg1, arg2);
    
            Fragment fragment = getParentFragment();
            if (fragment != null && fragment instanceof OnMapReadyListener) {
                ((OnMapReadyListener) fragment).onMapReady();
            }
    
            mTouchView = new TouchableWrapper(getActivity());
            mTouchView.addView(mapView);
    
            return mTouchView;
        }
    
        public static interface OnMapReadyListener {
            void onMapReady();
        }
    }
    
  3. Inside my Map_Fragment (which in the end will sit inside a FrameLayout in an activity that supports navigation drawer and fragment transactions for switching the views)

    mMapFragment = CustomMap_Fragment.newInstance();
    getChildFragmentManager().beginTransaction().replace(R.id.map_container, mMapFragment).commit();
    
  4. Now finally inside this same Fragment where I just got my map, the EventBus receiver will do the following action when it receives "double_tapped_map":

    @Subscribe public void eventBus_ListenerMethod(AnswerAvailableEvent event) {
        //Construct a CameraUpdate object that will zoom into the exact middle of the map, with a zoom of currentCameraZoom + 1 unit
       zoomInUpdate = CameraUpdateFactory.zoomIn();
       //Run that with a speed of 400 ms.
       map.animateCamera(zoomInUpdate, 400, null);
    }
    

Note: To achieve this perfectly you disable zoomGestures on your map (meaning you do myMap.getUiSettings().setZoomGesturesEnabled(false);. If you don't do that, you will be able to double-tap very quickly on the map and you will see that it will zoom away from the center because the implementation of double tap is exactly as I had in the first answer I posted, which is that they subtract current time from previous tap-time, so in that window you can slip in a third tap and it will not trigger the event bus event and google map will catch it instead; So disable Zoom gestures.

But then, you will see that pinch-in/out will not work anymore and you have to handle pinch also, which I've also done but needs like 1 more hour and I havent gotten the time to do that yet but 100% I will update the answer when I do that.

TIP: Uber has disabled rotate gestures on the map also. map.getUiSettings().setRotateGesturesEnabled(false);

Salep answered 8/5, 2015 at 7:53 Comment(12)
Can you add some more details. I have added map in my Activity. Using this and i want to add above feature in it.Susceptive
I've solved this thing in a better way recently, I will edit my answer to reflect all these changes, but I cant today unfortunatelySalep
Ok. If you can help, it will be nice. I am stuck at this point right now :)Susceptive
Thank you. @Odaym. If i will get any new update i will put it in your edit with description.Susceptive
It is working now :) Thanks for help, myMap.getUiSettings().setZoomGesturesEnabled(false);-tip, one helped meSusceptive
yes definitely need to set it to false to stop the map from handling any double taps, cause you cannot escape that small milisecond window where you do 3 taps and you miss the time difference that's implemented in Google's onDoubleTap() method. So glad that it worked!Salep
Thanks:), Now i am trying to put pinch to zoom effect for same. Have you implemented that too?Susceptive
Yea I have but..I can't really give this answer inside a comment because no one will notice it and maybe only you will benefit, ask it as a new question and I'll be more than glad to post what I didSalep
I have put bounty on question too. so other people can get attention too.Susceptive
@YuliyaTarasenko this is my attempted answer at Pinch gesture: https://mcmap.net/q/89307/-how-to-make-google-make-in-center-when-using-double-tap-and-pinch-to-zoom-in-androidSalep
@Odaym, zoom is not smooth , velocity is not getting used, can you please complete the code.Lona
I left the code at this exact point and that was the last day at that job unfortunately, it's been 8 months now. Please try to mess around with it I can't make the time today for it. Zoom should be smooth, it's pinch that I left at a point where it still wasn't smooth, but zoom is from the public functions of the map, with the right speed it should be smooth :\Salep
C
2

Personally, I would disable only zoom gestures on the map, detect pinch on an overlay, and then pass everything else through to the map.

The google-maps v2 API doesn't have anything explicit for custom zoom handling. Although I'm sure you could inject something, doing the overlay approach insulates you from google-maps changes, and lets you more easily support other map providers if needed.

(Just for completeness: you could also handle the post-camera change events and re-center, but that would be a janky, bad user experience.)

Concentrated answered 30/3, 2015 at 18:5 Comment(1)
Thanks for the reply. Yeah, that's what I was thinking I would need to do. I tried a few hacky ways of doing the re-centering like you said. And you're totally right, super janky. hahaScissure
A
1

I had the same requirement as well. I had to understand how the events are handled in android to solve this problem, because we have to intercept the touch event for zoom and pass the scroll event to the map. To achieve this, we need a custom View over Google map View. Our custom view intercepts touch events, and decides whether to handle the follow-up events by not giving a chance for underlying map to handle or just leave the underlying map to handle all by itself.

Now code time - We need two things here - a custom fragment, a custom view.

  1. Custom fragment

    public class CustomMapFragment extends SupportMapFragment implements OnMapReadyCallback {
    
    public View mapView = null;
    
    public WrapperView wrapperView = null;
    
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) {
        mapView = super.onCreateView(inflater, parent, savedInstanceState);
        wrapperView = new WrapperView(getActivity());
        wrapperView.addView(mapView);
        SupportMapFragment mapFragment = (SupportMapFragment) getActivity().getSupportFragmentManager().findFragmentById(R.id.map);
        mapFragment.getMapAsync(this);
        return wrapperView;
    }
    
    @Override
    public View getView() {
        return mapView;
    }
    
    @Override
    public void onMapReady(GoogleMap googleMap) {
        wrapperView.setGoogleMap(googleMap);
    }
    
  2. Custom View

    public class WrapperView extends FrameLayout {
    
    private GoogleMap googleMap;
    
    Activity activity = null;
    
    ScaleGestureDetector scaleGestureDetector;
    
    public WrapperView(Activity activity) {
        super(activity);
        this.activity=activity;
        scaleGestureDetector = new ScaleGestureDetector(activity ,new MyOnScaleGestureListener());
    }
    
    public void setGoogleMap(GoogleMap map){
        googleMap = map;
    }
    
    private boolean isZoomInProgress(MotionEvent event){
        if(event.getPointerCount()>1){
            return true;
        }
        return false;
    }
    
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event){
        return isZoomInProgress(event);
    }
    
    @Override
    public boolean onTouchEvent(MotionEvent event){
        return scaleGestureDetector.onTouchEvent(event);
    }
    
    public class MyOnScaleGestureListener extends
            ScaleGestureDetector.SimpleOnScaleGestureListener {
    
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            float previousSpan = detector.getPreviousSpan();
            float currentSpan = detector.getCurrentSpan();
            float targetSpan;
            if(previousSpan>currentSpan){
                targetSpan = previousSpan-currentSpan;
            }else{
                targetSpan = currentSpan-previousSpan;
            }
            float scaleFactor = detector.getScaleFactor();
            if (scaleFactor > 1) {
                if(googleMap.getCameraPosition().zoom!=googleMap.getMaxZoomLevel()) {
                    for(int j=0;j<(targetSpan*2);j++){
                        googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(googleMap.getCameraPosition().target, googleMap.getCameraPosition().zoom + 0.002f));
                    }
                }
            } else {
                if (googleMap.getCameraPosition().zoom != googleMap.getMinZoomLevel()) {
                    for(int j=0;j<(targetSpan*2);j++){
                        googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(googleMap.getCameraPosition().target, googleMap.getCameraPosition().zoom - 0.002f));
                    }
                }
            }
            return true;
        }
    
        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            return true;
        }
    
        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {}
    }
    

Use the new custom fragment in your view like below -

 <fragment xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:map="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/map"
    android:name="yourpackage.CustomMapFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" />
Aluminium answered 12/8, 2019 at 12:42 Comment(0)
A
0

You can use LatLngBounds to limit the map be move from the position you want. (You can set both Northeast and Southwest corner of the bound be the same point) .

Please check the below link.

https://developers.google.com/maps/documentation/android-api/views

Alurta answered 31/5, 2017 at 15:37 Comment(1)
this article is about zooming programatically but not pinch-to-zoomPhyte
L
0

This worked for me in compose Use the below CustomMapView in place of MapView

internal class CustomMapView : MapView {
private var googleMap: GoogleMap? = null
private var gestureDetector: ScaleGestureDetector? = null
private val mHandler = Handler(Looper.getMainLooper())
private var touchDownTime = 0L
private var fingers = 0
private var doubleClicked = false
private val doubleClickInterval = ViewConfiguration.getDoubleTapTimeout().toLong()
private var clickCount = 0

constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, style: Int) : super(context, attrs, style)
constructor(context: Context, options: GoogleMapOptions?) : super(context, options)

fun init(map: GoogleMap?, context: Context) {
    map ?: return
    this.googleMap = map
    initScaleGestureDetector(map, context)
}

override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean {
    when (motionEvent.action.and(MotionEvent.ACTION_MASK)) {
        MotionEvent.ACTION_POINTER_DOWN -> fingers += 1
        MotionEvent.ACTION_POINTER_UP -> fingers -= 1
        MotionEvent.ACTION_UP -> {
            val time = System.currentTimeMillis() - touchDownTime
            if (clickCount == 2) {
                doubleClicked = time <= doubleClickInterval
                clickCount = 0
            } else {
                doubleClicked = false
                fingers = 0
            }
        }
        MotionEvent.ACTION_DOWN -> {
            doubleClicked = false
            fingers = 1
            if (System.currentTimeMillis() - touchDownTime >= 300) {
                clickCount = 0
            }
            touchDownTime = System.currentTimeMillis()
            clickCount++
        }
    }
    if (fingers > 1 || doubleClicked) {
        disableScrolling()
        if (doubleClicked) {
            try {
                val zoomValue: Float = googleMap!!.cameraPosition.zoom + 1.0f
                googleMap!!.animateCamera(CameraUpdateFactory.zoomTo(zoomValue))
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    } else if (fingers < 1) {
        enableScrolling()
    }

    gestureDetector?.onTouchEvent(motionEvent)
    return super.dispatchTouchEvent(motionEvent)
}

var lastSpan = -1f
var lastZoomTime = 0L
private fun initScaleGestureDetector(map: GoogleMap, context: Context) {
    gestureDetector = ScaleGestureDetector(
        context,
        object : ScaleGestureDetector.OnScaleGestureListener {
            override fun onScale(detector: ScaleGestureDetector): Boolean {
                if (lastSpan == -1f) {
                    lastSpan = detector.currentSpan
                } else if (detector.eventTime - lastZoomTime >= 50) {
                    lastZoomTime = detector.eventTime
                    map.moveCamera(
                        CameraUpdateFactory.zoomBy(
                            getZoomValue(
                                detector.currentSpan,
                                lastSpan
                            )
                        )
                    )
                    lastSpan = detector.currentSpan
                }
                return false
            }

            override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
                lastSpan = -1f
                return true
            }

            override fun onScaleEnd(detector: ScaleGestureDetector) {
                lastSpan = -1f
            }
        }
    )
}

private fun getZoomValue(currentSpan: Float, lastSpan: Float): Float {
    val value = ln((currentSpan / lastSpan).toDouble()) / ln(1.55)
    return value.toFloat()
}

private fun enableScrolling() {
    val map = googleMap ?: return
    if (!map.uiSettings.isScrollGesturesEnabled) {
        mHandler.postDelayed(
            { map.uiSettings.setAllGesturesEnabled(true) },
            50
        )
    }
}

private fun disableScrolling() {
    val map = googleMap ?: return
    if (map.uiSettings.isScrollGesturesEnabled) {
        map.uiSettings.setAllGesturesEnabled(false)
    }
}

}

Ledesma answered 11/4, 2023 at 4:49 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.