Wednesday, August 19, 2015

Some twists of using Google Maps in your Android app - Part 1: Smart follow

Following the user's location only when he hasn't panned the map away (as otherwise the view will animate back to the current location every now and then, providing quite a frustrating experience) is a commonly used functionality in applications with maps (say, tracking your route and such). I kinda feel there should be an out-of-the-box method for this in Google Maps SDK, but whatever, here you go if you want to implement it.

Starting with:
googleMap.setMyLocationEnabled(true);
we get
  1. a "locate me" button in the top right corner of the map
  2. a blue dot representing the user's current location.
We can also implement a listener for when the user's location changes (extracted methods for clarity):

@Override
public void onMapReady(GoogleMap googleMap) {
    googleMap.setMyLocationEnabled(true);
    googleMap.setOnMyLocationChangeListener(getLocationChangeListener(googleMap));
}

private GoogleMap.OnMyLocationChangeListener getLocationChangeListener(final GoogleMap googleMap) {
    return new GoogleMap.OnMyLocationChangeListener() {
        @Override
        public void onMyLocationChange(Location location) {
            cameraUpdate = getCameraUpdate(location);
            googleMap.animateCamera(cameraUpdate);
        }
    };
}

private CameraUpdate getCameraUpdate(Location location) {
    CameraPosition cameraPosition = new CameraPosition.Builder()
            .target(new LatLng(location.getLatitude(), location.getLongitude()))
            .zoom(17)
            .build()
    ;

    return CameraUpdateFactory.newCameraPosition(cameraPosition);
}

This way whenever the location changes, the camera will animate to the new location. So far so good. Trouble arises when you drag the map away to maybe look at something. On the next location update the camera will animate back automatically, much to your (and the user's) frustration.

We could have a boolean flag to flip whenever the map is dragged, and reset it when the user clicks again on the "locate me" button. Then in the OnMyLocationChangeListener we could decide whether to animate the camera or leave it be based on this flag.

Unfortunately, the API does not provide a method specifically to listen to map drag events (only for markers), but there's a workaround. What you can do is to create an invisible wrapper class:

public class TouchableWrapper extends FrameLayout {
    private static final int DEFAULT_THRESHOLD_MS = 100;
    private final Callbacks callbacks;
    private final long thresholdMs;
    private long lastTouched = 0;

    public TouchableWrapper(Context context, Callbacks callbacks) {
        this(context, callbacks, DEFAULT_THRESHOLD_MS);
    }

    public TouchableWrapper(Context context, Callbacks callbacks, long thresholdMs) {
        super(context);
        this.callbacks = callbacks;
        this.thresholdMs = thresholdMs;
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        callbackIfNeeded(event);

        return super.dispatchTouchEvent(event);
    }

    private void callbackIfNeeded(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastTouched = SystemClock.uptimeMillis();
                break;

            case MotionEvent.ACTION_UP:
                if (SystemClock.uptimeMillis() - lastTouched > thresholdMs) {
                    callbacks.onWrapperTouchReleased();
                }

                break;
        }
    }

    public interface Callbacks {
        public void onWrapperTouchReleased();
    }
}

What this does is it overrides dispatchTouchEvent (method inherited from ViewGroup), and checks for touch / release events. When the elapsed time between those two passes a threshold value, it fires the onWrapperTouchReleased event on the object implementing the inner Callbacks interface. You can pass in that object in in the constructor.

Making use of this class in your custom MapFragment:

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) {
    View view = super.onCreateView(inflater, parent, getMapFragmentState(savedInstanceState));
    TouchableWrapper touchableWrapper = new TouchableWrapper(getActivity(), this);
    touchableWrapper.addView(view);

    return touchableWrapper;
}

You inflate both the parent's layout and the wrapper, add the original content inside the wrapper, and return the wrapper. Simple enough. In the TouchableWrapper I passed in this as the Callback implementing class, but you can pass in another object, however you like.

Now, onto implementing the actual tracking boolean flag:

@InstanceState
boolean followMyLocation;

Override
public void onWrapperTouchReleased() {
    followMyLocation = false;
}
Make sure you save the boolean value in onSaveInstanceState() so that its state will not be lost. In my case I'm using Android Annotations and @InstanceState does the job for me. And wherever you handle onMapReady(), now add a custom listener for clicks on the "locate me" button:

@Override
public void onMapReady(GoogleMap googleMap) {
    googleMap.setMyLocationEnabled(true);
    googleMap.setOnMyLocationButtonClickListener(getMyLocationButtonOnClickListener());
    googleMap.setOnMyLocationChangeListener(getLocationChangeListener(googleMap));
}

private GoogleMap.OnMyLocationButtonClickListener getMyLocationButtonOnClickListener() {
    return new GoogleMap.OnMyLocationButtonClickListener() {
        @Override
        public boolean onMyLocationButtonClick() {
            followMyLocation = true;
            return false;
        }
    };
}

And now you can check the boolean flag to conditionally update the location:

private GoogleMap.OnMyLocationChangeListener getLocationChangeListener(final GoogleMap googleMap) {
    return new GoogleMap.OnMyLocationChangeListener() {
        @Override
        public void onMyLocationChange(Location location) {
            if (followMyLocation) {
                CameraUpdate cameraUpdate = getCameraUpdate(location);
                googleMap.animateCamera(cameraUpdate);
            }
        }
    };
}

And there you go!

See also: Part 2 - Adding a Floating Action Button and fighting BadParcelableException

No comments:

Post a Comment