Google MapでZoom event/Scroll event取得 - MapViewの拡張

再度、前回に続いてGoogle Maps APIを使用した実装に関するPostになります。Google Maps APIを使用していて、Zoom level変更event、或はMapのCenter position変更event(Scroll event)を取得したいと思い、調べてみたところ残念ながらAPIは提供されていない模様。ということで更に調べているとStackoverflowでこういうPostを見かけました。このPostを参考にUser interaction以外でもMapView#animateTo()といったCenter position変更eventを取得出来るように改良してみました(このPostのままだと自分がやりたい事をやった際に上手くいきませんでした)

今回やってみたいこと

  1. MapViewを拡張しZoom level変更event、Center position変更eventを取得(Listenerに通知も)

ちなみに前回やったこと

  1. ItemizedOverlayを使用しtouchされたmapの場所にOverlayItemを表示
  2. OverlayItemがtapされた場合は、それが保持するLocation情報を表示


こんな感じでZoom levelが変更されるとToastを表示


こんな感じでScrollされるとToastを表示

実装の要点は下記です(前回実装したことに関しては省略)

1. MapViewを拡張したclassを作成

public class MyMapView extends MapView {
        @Override
	public boolean onTouchEvent(MotionEvent ev) {
		return super.onTouchEvent(ev);
	}
        @Override
	public void computeScroll() {
		super.computeScroll();
	}
}

2. MapViewを拡張したclassをlayout xmlに定義

<com.android.practice.map.MyMapView 
		android:id="@+id/map_view"
		android:layout_width="fill_parent"
		android:layout_height="fill_parent"
		android:clickable="true"
		android:apiKey="自分のGoogle Maps API key" />

3. Zoom level変更/Center position変更event listenerを作成

public interface ZoomListener{
	public void onZoomChange(MapView mapView, int newZoom, int oldZoom);
}

public interface PanListener{
	public void onCenterChange(MapView mapView, GeoPoint newGeoPoint, GeoPoint oldGeoPoint);
}

private class MyMapViewListener implements ZoomListener, PanListener{
	@Override
	public void onZoomChange(MapView mapView, int newZoom, int oldZoom) {
		Toast.makeText(MyMapActivity.this, "New zoom lv:" + newZoom + "\nOld zoom lv:" + oldZoom, Toast.LENGTH_SHORT).show();
	}

	@Override
	public void onCenterChange(MapView mapView, GeoPoint newGeoPoint,
			GeoPoint oldGeoPoint) {
		Toast.makeText(MyMapActivity.this, 
				"New center latitude:" + newGeoPoint.getLatitudeE6() + "\nNew center longitude:" + newGeoPoint.getLongitudeE6(), 
				Toast.LENGTH_SHORT).show();
	}
}

4. 3で作成したMapView event listener classをlistenerとしてMapView拡張classに登録

mMyMapViewListener = new MyMapViewListener();
if(mMapView != null){
	mMapView.setZoomListener(mMyMapViewListener);
	mMapView.setPanListener(mMyMapViewListener);
}

5. Zoom level変更用、Center position変更用の通知Runnableをそれぞれ作成

mZoomChangeNotifier = new Runnable(){
	@Override
	public void run() {
		mHandler.removeCallbacks(this);
		notifyZoomChange();
	}
};

mCenterChangeNotifier = new Runnable(){
	@Override
	public void run() {
		mHandler.removeCallbacks(this);
		notifyCenterChange();
	}
};

6. 移動Animation終了check用のRunnableを作成(User interaction以外でもZoom level/Center positionの変更を確認するため)

mAnimationFinishChecker = new Runnable(){
	@Override
	public void run() {
		mHandler.removeCallbacks(this);
		if(isAnimationFinished()){
			isDuringAnimation = false;
			mHandler.post(new Runnable(){
				@Override
				public void run() {
					mHandler.removeCallbacks(mZoomChangeNotifier);
					mHandler.removeCallbacks(mCenterChangeNotifier);
					if(mLastZoomLevel != getZoomLevel()){
						notifyZoomChange();
					}
					if(isCenterChanged()){
						notifyCenterChange();
					}
				}
			});
		}else{
			mHandler.postDelayed(this, EVENT_DELAY);
		}
	}
};

7. MapView#onTouchEvent()をoverrideしtouch中か移動Animation中かのflagを設定

@Override
public boolean onTouchEvent(MotionEvent ev) {
	
	int action = ev.getAction();
		
	switch(action & MotionEvent.ACTION_MASK){
	case MotionEvent.ACTION_DOWN:
		isTouched = true;
		isDuringAnimation = false;
		break;
	case MotionEvent.ACTION_UP:
		isTouched = false;
		break;
	}
	return super.onTouchEvent(ev);
}

8. MapView#computeScroll()をoverrideしZoom level変更/Center position変更の確認。確認には6で作成したRunnableをHandlerでpost

@Override
public void computeScroll() {
        super.computeScroll();

	if(!isDuringAnimation){
		checkAndNotifyChanges(EVENT_DELAY);
	}
}

private void checkAndNotifyChanges(long delay){
	if(getZoomLevel() != mLastZoomLevel){
		mHandler.postDelayed(mZoomChangeNotifier, delay);
	}

	if(isCenterChanged() && !isTouched){
		mHandler.postDelayed(mCenterChangeNotifier, delay);
	}
}

長々となりましたが、こんな感じです

気付いた点をまとめます

  • UI上移動Animationは終了しているが、移動距離が長い場合MapVeiw#computeScroll()が実行されている場合がある(実装を気をつけないとcomputeScroll()でevent通知を行っている為、変なtimingでevent通知がされる)

それでは実装です

MapActivityなど(MapViewのevent listener class追加、MapController関係code削除以外前回と同じ)

package com.android.practice.map;

import java.util.List;

import com.android.practice.map.MyMapView.PanListener;
import com.android.practice.map.MyMapView.ZoomListener;
import com.google.android.maps.GeoPoint;
import com.google.android.maps.MapActivity;
import com.google.android.maps.MapController;
import com.google.android.maps.MapView;
import com.google.android.maps.MyLocationOverlay;
import com.google.android.maps.Overlay;

import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.location.Location;
import android.location.LocationManager;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;

//MapActivityを拡張したclassを作成
public class MyMapActivity extends MapActivity{
	
	private static final String TAG = "MyMapActivity";
	private static final boolean DEBUG = true;
	
	private static final String ACTION_LOCATION_UPDATE = "com.android.practice.map.ACTION_LOCATION_UPDATE";
	
	private MyMapView mMapView;
	private MyLocationOverlay mMyLocationOverlay;
	private MyItemizedOverlay mItemizedOverlay;
	
	private MyMapViewListener mMyMapViewListener; 
	
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        initMapSet();
        setMapEventListeners();
    }
    
	@Override
	protected void onResume() {
		super.onResume();
		setOverlays();
		setIntentFilterToReceiver();
		requestLocationUpdates();
	}
    
	@Override
	protected void onPause() {
		super.onPause();
		resetOverlays();
		removeUpdates();
	}
	
	@Override
	protected void onDestroy() {
		super.onDestroy();
	}

	@Override
	protected boolean isRouteDisplayed() {
		return false;
	}
	
	private void initMapSet(){
		//MyMapView objectの取得
        mMapView = (MyMapView)findViewById(R.id.map_view);
        //MapView#setBuiltInZoomControl()でZoom controlをbuilt-in moduleに任せる
        mMapView.setBuiltInZoomControls(true);
        //MapController objectを取得
        //mMapController = mMapView.getController();
	}
	
	//作成したMapView event listener classをlistenerとしてMapView拡張classに登録
	private void setMapEventListeners(){
		mMyMapViewListener = new MyMapViewListener();
		if(mMapView != null){
			mMapView.setZoomListener(mMyMapViewListener);
			mMapView.setPanListener(mMyMapViewListener);
		}
	}
	
	private void setOverlays(){
        //User location表示用のMyLocationOverlay objectを取得
		mMyLocationOverlay = new MyLocationOverlay(this, mMapView);
		//初めてLocation情報を受け取った時の処理を記載
		//試しにそのLocationにanimationで移動し、zoom levelを19に変更
        mMyLocationOverlay.runOnFirstFix(new Runnable(){
        	public void run(){
        		GeoPoint gp = mMyLocationOverlay.getMyLocation();
				mMapView.animateTo(gp);
				mMapView.setZoom(19);
        	}
        });
        //LocationManagerからのLocation update取得
		mMyLocationOverlay.enableMyLocation();
		
		//OverlayItemを表示するためのMyItemizedOverlayを拡張したclassのobjectを取得
		mItemizedOverlay = new MyItemizedOverlay(getResources().getDrawable(R.drawable.icon), this);
		
		//overlayのlistにMyLocationOverlayを登録
        List<Overlay> overlays = mMapView.getOverlays();
        overlays.add(mMyLocationOverlay);
        //overlayのlistに拡張したItemizedOverlayを拡張したclassを登録
        overlays.add(mItemizedOverlay);
	}
	
	private void resetOverlays(){
        //LocationManagerからのLocation update情報を取得をcancel
		mMyLocationOverlay.disableMyLocation();
		
		//overlayのlistからMyLocationOverlayを削除
		List<Overlay> overlays = mMapView.getOverlays();
        overlays.remove(mMyLocationOverlay);
		//overlayのlistからItemizedOverlayを拡張したclassを削除        
        overlays.remove(mItemizedOverlay);
	}
	
	private void setIntentFilterToReceiver(){
		final IntentFilter filter = new IntentFilter();
    	filter.addAction(ACTION_LOCATION_UPDATE);
    	registerReceiver(new LocationUpdateReceiver(), filter);
	}
	
	private void requestLocationUpdates(){
		final PendingIntent requestLocation = getRequestLocationIntent(this);
		LocationManager lm = (LocationManager)getSystemService(Context.LOCATION_SERVICE);
        for(String providerName: lm.getAllProviders()){
			if(lm.isProviderEnabled(providerName)){
				lm.requestLocationUpdates(providerName, 0, 0, requestLocation);
				if(DEBUG){
					Toast.makeText(this, "Request Location Update", Toast.LENGTH_SHORT).show();
					if(DEBUG)Log.d(TAG, "Provider: " + providerName);
				}
			}
		}
	}
	
	private PendingIntent getRequestLocationIntent(Context context){
		return PendingIntent.getBroadcast(context, 0, new Intent(ACTION_LOCATION_UPDATE), 
				PendingIntent.FLAG_UPDATE_CURRENT);
	}
	
	private void removeUpdates(){
    	final PendingIntent requestLocation = getRequestLocationIntent(this);
		LocationManager lm = (LocationManager)getSystemService(Context.LOCATION_SERVICE);
		lm.removeUpdates(requestLocation);
		if(DEBUG)Toast.makeText(this, "Remove update", Toast.LENGTH_SHORT).show();    	
    }
	
	private class LocationUpdateReceiver extends BroadcastReceiver{
		
		@Override
		public void onReceive(Context context, Intent intent) {
			String action = intent.getAction();
			if(action == null){
				return;
			}
			if(action.equals(ACTION_LOCATION_UPDATE)){
				//Location情報を取得
				Location loc = (Location)intent.getExtras().get(LocationManager.KEY_LOCATION_CHANGED);
				if(loc != null){
					//試しにMapControllerで現在値に移動する
					mMapView.animateTo(new GeoPoint((int)(loc.getLatitude() * 1E6), (int)(loc.getLongitude() * 1E6)));
					if(DEBUG)Toast.makeText(context, "latitude:" + loc.getLatitude() + "\nlongitude:" + loc.getLongitude(), Toast.LENGTH_SHORT).show();
				}
			}
		}
	}
	
	//Zoom level変更/Center position変更event listenerを作成
	private class MyMapViewListener implements ZoomListener, PanListener{
		
		@Override
		public void onZoomChange(MapView mapView, int newZoom, int oldZoom) {
			Toast.makeText(MyMapActivity.this, "New zoom lv:" + newZoom + "\nOld zoom lv:" + oldZoom, Toast.LENGTH_SHORT).show();
		}

		@Override
		public void onCenterChange(MapView mapView, GeoPoint newGeoPoint,
				GeoPoint oldGeoPoint) {
			Toast.makeText(MyMapActivity.this, 
					"New center latitude:" + newGeoPoint.getLatitudeE6() + "\nNew center longitude:" + newGeoPoint.getLongitudeE6(), 
					Toast.LENGTH_SHORT).show();
		}
	}
}

MapView拡張class

package com.android.practice.map;

import java.util.ArrayList;

import android.content.Context;
import android.os.Handler;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;

import com.google.android.maps.GeoPoint;
import com.google.android.maps.MapView;

//MapViewを拡張したclassを作成
public class MyMapView extends MapView {
	
	private static final String TAG = "MyMapView";
	private static final boolean DEBUG = true;
	
	private long EVENT_DELAY = 500L;
	
	private final Handler mHandler;
	private final Runnable mZoomChangeNotifier;
	private final Runnable mCenterChangeNotifier;
	private final Runnable mAnimationFinishChecker;
	
	private ArrayList<ZoomListener> mZoomListeners;
	private int mLastZoomLevel;
	
	private ArrayList<PanListener> mPanListeners;
	private int mLastCenterLati;
	private int mLastCenterLongi;
	private int mAnimationToLati;
	private int mAnimationToLongi;
	
	private boolean isTouched;
	private boolean isDuringAnimation;
	
	public MyMapView(Context arg0, AttributeSet arg1) {
		super(arg0, arg1);
		
		//Handler objectを取得
		mHandler = new Handler();
		//Zoom level変更用、Center position変更用の通知Runnableをそれぞれ作成
		//Zoom level変更を通知するRunnableを作成
		mZoomChangeNotifier = new Runnable(){
			@Override
			public void run() {
				//前回postしたZoom level変更を通知するRunnableが残っていればcancelする
				mHandler.removeCallbacks(this);
				notifyZoomChange();
			}
		};
		//ZoomListenerを保持するArrayListを作成
		mZoomListeners = new ArrayList<ZoomListener>();
		mLastZoomLevel = getZoomLevel();
		
		//Center position変更を通知するRunnableを作成
		mCenterChangeNotifier = new Runnable(){
			@Override
			public void run() {
				//前回postしたcenter position変更を通知するRunnableが残っていればcancelする
				mHandler.removeCallbacks(this);
				notifyCenterChange();
			}
		};
		//PanListenerを保持するArrayListを作成
		mPanListeners = new ArrayList<PanListener>();
		GeoPoint center = getMapCenter();
		mLastCenterLati = center.getLatitudeE6();
		mLastCenterLongi = center.getLongitudeE6();
		
		//移動Animation終了check用のRunnableを作成
		mAnimationFinishChecker = new Runnable(){
			@Override
			public void run() {
				mHandler.removeCallbacks(this);
				//移動Animationが終了していればZoom level変更、Center position変更確認後、listenerに通知
				if(isAnimationFinished()){
					isDuringAnimation = false;
					mHandler.post(new Runnable(){
						@Override
						public void run() {
							mHandler.removeCallbacks(mZoomChangeNotifier);
							mHandler.removeCallbacks(mCenterChangeNotifier);
							//UI上移動Animationは終了しているが、移動距離が長い場合MapVeiw#computeScroll()が実行されている場合があるので
							//computeScroll() -> checkAndNotifyChanges()による通知cancelを避けるために
							//mZoomChangeNotifier, mCetnerChangeNotifierをpostせずにListenerに通知
							if(mLastZoomLevel != getZoomLevel()){
								notifyZoomChange();
							}
							if(isCenterChanged()){
								notifyCenterChange();
							}
						}
					});
				//移動Animationが終了していなければ再度、自分をpostする
				}else{
					mHandler.postDelayed(this, EVENT_DELAY);
				}
			}
		};
		
		isTouched = false;
		isDuringAnimation = false;
	}
	
	//MapView#onTouchEvent()をoverrideしtouch中か移動Animation中かのflagを設定
	@Override
	public boolean onTouchEvent(MotionEvent ev) {
		
		int action = ev.getAction();
		
		switch(action & MotionEvent.ACTION_MASK){
		case MotionEvent.ACTION_DOWN:
			if(DEBUG)Log.d(TAG, "ACTION_DOWN");
			isTouched = true;
			isDuringAnimation = false;
			break;
		case MotionEvent.ACTION_UP:
			if(DEBUG)Log.d(TAG, "ACTION_UP");
			isTouched = false;
			break;
		}

		return super.onTouchEvent(ev);
	}

	//MapView#computeScroll()をoverrideしZoom level変更/Center position変更の確認
	@Override
	public void computeScroll() {
		super.computeScroll();
		if(DEBUG)Log.d(TAG, "computeScroll");
		//移動Animation中のZoom level変更, Center position変更は通知しない
		if(!isDuringAnimation){
			checkAndNotifyChanges(EVENT_DELAY);
		}
	}
	
	private void checkAndNotifyChanges(long delay){
		//直近に取得したZoom levelと異なるZoom levelか確認
		if(getZoomLevel() != mLastZoomLevel){
			if(DEBUG)Log.d(TAG, "Zoom level changed");
			//Zoom level変更を通知するRunnableをpost
			mHandler.postDelayed(mZoomChangeNotifier, delay);
		}else{
			if(DEBUG)Log.d(TAG, "Zoom level NOT changed");
		}
		//直近に取得したcenter positionと現在のcenter positionが異なるか確認
		//touch中か確認
		if(isCenterChanged() && !isTouched){
			//Center position変更を通知するRunnableをpost
			mHandler.postDelayed(mCenterChangeNotifier, delay);
		}
	}
	
	private boolean isCenterChanged(){
		GeoPoint gp = getMapCenter();
		if(mLastCenterLati == gp.getLatitudeE6() && mLastCenterLongi == gp.getLongitudeE6()){
			if(DEBUG)Log.d(TAG, "Center NOT changed");
			return false;
		}
		if(DEBUG)Log.d(TAG, "Center changed");
		return true;
	}

	public void animateTo(GeoPoint gp){
		isDuringAnimation = true;
		//どこが移動Animation終了後のCenter positionになるべきか保持しておく
		mAnimationToLati = gp.getLatitudeE6();
		mAnimationToLongi = gp.getLongitudeE6();
		getController().animateTo(gp);
		//移動Animationが終了したかどうかの確認の為のRunnableをpostする
		mHandler.postDelayed(mAnimationFinishChecker, EVENT_DELAY);
	}
	
	private boolean isAnimationFinished(){
		GeoPoint center = getMapCenter();
		int centerLati = center.getLatitudeE6();
		int centerLongi = center.getLongitudeE6();
		
		if(DEBUG){
			Log.d(TAG, "mAnimationToLati:" + mAnimationToLati);
			Log.d(TAG, "mAnimationToLongi:" + mAnimationToLongi);
			Log.d(TAG, "centerLati:" + centerLati);
			Log.d(TAG, "centerLongi:" + centerLongi);
		}
		
		if(centerLati == mAnimationToLati && centerLongi == mAnimationToLongi){
			return true;
		}
		
		return false;
	}
	
	public void setZoom(int zoomLevel){
		getController().setZoom(zoomLevel);
		//移動Animationが終了したかどうかの確認の為のRunnableをpostする
		mHandler.postDelayed(mAnimationFinishChecker, EVENT_DELAY);
	}	
	
	public void setZoomListener(ZoomListener listener){
		mZoomListeners.add(listener);
	}
	
	public void notifyZoomChange(){
		if(DEBUG)Log.d(TAG, "New zoom level: " + getZoomLevel());
		int newZoomLevel = getZoomLevel();
		for(ZoomListener listener: mZoomListeners){
			//Zoom level変更をlistenerに通知
			listener.onZoomChange(MyMapView.this, newZoomLevel, mLastZoomLevel);
		}
		//現Zoom levelを直近のZoom levelとして保持
		mLastZoomLevel = newZoomLevel;
	}
	
	public void setPanListener(PanListener listener){
		mPanListeners.add(listener);
	}
	
	public void notifyCenterChange(){
		if(DEBUG)Log.d(TAG, "notify center change");
		
		for(PanListener listener: mPanListeners){
			//Center position変更をlistenerに通知
			listener.onCenterChange(MyMapView.this, getMapCenter(), new GeoPoint(mLastCenterLati, mLastCenterLongi));
		}
		//現Center positionを直近のCenter positionとして保持
		mLastCenterLati = getMapCenter().getLatitudeE6();
		mLastCenterLongi = getMapCenter().getLongitudeE6();
	}

	public interface ZoomListener{
		public void onZoomChange(MapView mapView, int newZoom, int oldZoom);
	}
	
	public interface PanListener{
		public void onCenterChange(MapView mapView, GeoPoint newGeoPoint, GeoPoint oldGeoPoint);
	}
	
}

という感じです。今回はTimerをHandlerに変更したくらいでチャチャっと終わるかなと思ってたんですが、MapView#animateTo()を考慮した処理に苦労しました。まだ不具合あるかも...

HashTag #Java, #Android, #MapActivity, #MapView