Monday, January 3, 2011

 

Two great maps that taste great together

I spent yesterday adding OSMDroid support to iTravelFree; here's a little precis of what I learned about letting users swap back and forth between Google Maps and OpenStreetMaps within the same Android Activity.

Why? you might ask: well, two reasons. One, OpenStreetMaps is a pretty cool project. Two, OSMDroid lets you access OpenStreetMaps even while offline, which is pretty awesome for a travel app, and while Google Maps App 5.0 has some offline support, it not you decides which tiles get cached. Mind you, creating and downloading OSM maps to Android is non-trivial, though the next iTravelFree release will make it much easier.

Also, it's just kinda interesting swapping back and forth between GMaps and OSM and seeing the differences between the two.

Anyway: how to do it? With a basic Decorator pattern, ie we roll our own MapView object (in my case, ITRMapView) which wraps both a Google MapView and and OpenStreetMapView, and forwards method calls appropriately depending on its state.

Arguably the best implementation would be via a common ITRMapViewImplementation interface and a concrete class for every map type. (If there were more than two map types, this would no longer really be arguable.) But I implemented it quick-and-dirty, with a bunch of if statements, which does at least have the serendipitous effect of being easy to use as an example.

Anyhoo, without further ado, here's the code:

/**
* Wrapper class which lets a MapActivity switch back and forth between Google Maps and OpenStreetMaps.
*/
public class ITRMapView {
private MapActivity mapActivity;
private BalloonLayout balloon;

private ITRGMapView gmap;
public ITRGMapView getGMap()
{
if (gmap==null) {
gmap = new ITRGMapView(mapActivity, "my_api_key");
gmap.setClickable(true);
gmap.setMapActivity(mapActivity);
gmap.setBuiltInZoomControls(true);
gmapLocation = new com.google.android.maps.MyLocationOverlay(mapActivity, gmap);
balloon = BalloonLayout.GetBalloonFor(mapActivity);
}
return gmap;
}
private com.google.android.maps.MyLocationOverlay gmapLocation;
private ListingsOverlay gmapListingsOverlay;

private OpenStreetMapView osm;
public OpenStreetMapView getOSMMap() { return osm; }
private org.andnav.osm.views.overlay.MyLocationOverlay osmLocation;
private OpenStreetMapViewItemizedOverlay<OpenStreetMapViewOverlayItem> osmListingsOverlay;

private boolean osmActive=false;
public boolean isOSMActive() { return osmActive; }
public void setOSMActive(boolean doOSM) { osmActive=doOSM; }

public ITRMapView(MapActivity activity) {
mapActivity=activity;
}

public void setOSMMap(OpenStreetMapView theOSMMap) {
osm=theOSMMap;
osmLocation = new org.andnav.osm.views.overlay.MyLocationOverlay(mapActivity, osm);
int centerLat = gmap==null ? 0 : gmap.getMapCenter().getLatitudeE6();
int centerLon = gmap==null ? 0 : gmap.getMapCenter().getLongitudeE6();
int zoom = gmap==null ? 10 : gmap.getZoomLevel();
org.andnav.osm.util.GeoPoint center = new org.andnav.osm.util.GeoPoint(centerLat, centerLon);
osm.getController().setCenter(center);
osm.getController().setZoom(zoom);
osm.setUpFor(mapActivity);
}

public void onResume() {
if (gmapLocation!=null) {
gmapLocation.enableMyLocation();
gmapLocation.enableCompass();
}
if (osmLocation!=null) {
osmLocation.enableMyLocation();
osmLocation.enableCompass();
}
}
public void onPause() {
if (gmapLocation!=null) {
gmapLocation.disableMyLocation();
gmapLocation.disableCompass();
}
if (osmLocation!=null) {
osmLocation.disableMyLocation();
osmLocation.disableCompass();
}
}

public void centerAndZoom(int centerLat, int centerLon, int spanLat, int spanLon) {
Log.i(""+this, "Center and zoom: "+centerLat+" "+centerLon+" "+spanLat+" "+spanLon+" osm "+osmActive);
if (osmActive) {
org.andnav.osm.util.GeoPoint center = new org.andnav.osm.util.GeoPoint(centerLat, centerLon);
OpenStreetMapViewController controller = osm.getController();
controller.setCenter(center);

//quick-and-dirty, because zoomToSpan doesn't work
Integer maxSpan = Math.max(spanLat, spanLon);
Double ln = Math.log(maxSpan.doubleValue());
Integer zoomLevel = 20-ln.intValue();
if (zoomLevel>5)
zoomLevel+=2;
if (zoomLevel>18)
zoomLevel=18;
controller.setZoom(zoomLevel);
}
else {
com.google.android.maps.GeoPoint center = new com.google.android.maps.GeoPoint(centerLat, centerLon);
MapController controller = gmap.getController();
controller.setCenter(center);
controller.zoomToSpan(spanLat, spanLon);
}
}

public int getMapCenterLatE6() {
if (osmActive) {
int lat = osm.getMapCenterLatitudeE6();
//Need to clamp to be consistent with GMaps
if (lat<-180000000)
lat+=360000000;
if (lat>180000000)
lat-=360000000;
return lat;
}
return gmap.getMapCenter().getLatitudeE6();
}
public int getMapCenterLonE6() {
if (osmActive) {
int lon = osm.getMapCenterLongitudeE6();
//Need to clamp to be consistent with GMaps
if (lon<-180000000)
lon+=360000000;
if (lon>180000000)
lon-=360000000;
return lon;
}
return gmap.getMapCenter().getLongitudeE6();
}

public void fillMap(ProgressIndicator indicator, HashSet<MappableItem> mCurrentSubset) {
if (osmActive)
fillOSMMap(indicator, mCurrentSubset);
else
fillGMap(indicator, mCurrentSubset);
}
synchronized void fillOSMMap(ProgressIndicator indicator, HashSet<MappableItem> mCurrentSubset) {
if (mCurrentSubset==null)
return;

ArrayList<OpenStreetMapViewOverlayItem> items = new ArrayList<OpenStreetMapViewOverlayItem>();
MappableItem[] toMap = mCurrentSubset.toArray(new MappableItem[mCurrentSubset.size()]);
int listEnd=Math.min(toMap.length, Settings.GetMapMax());
for (int i=0; i<listEnd; i++) {
OpenStreetMapViewOverlayItem newItem = new OpenStreetMapViewOverlayItem(
toMap[i].getIDString(),
toMap[i].getMapDetailText(),
new GeoPoint(toMap[i].getLatitudeE6(), toMap[i].getLongitudeE6()));
newItem.setMarker(toMap[i].getMarker(mapActivity));
items.add(newItem);
if (indicator!=null)
indicator.showProgress(i, listEnd);
}

osmListingsOverlay = new OpenStreetMapViewItemizedOverlay<OpenStreetMapViewOverlayItem>(
mapActivity,items,
new OpenStreetMapViewItemizedOverlay.OnItemGestureListener<OpenStreetMapViewOverlayItem>(){
@Override
public boolean onItemSingleTapUp(int index, OpenStreetMapViewOverlayItem item) {
RelativeLayout rl = (RelativeLayout) osm.getParent();
rl.removeView(balloon);
balloon.disablePointer();
balloon.setUpTapFor(mapActivity, item.getTitle(), item.getSnippet());
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(200, 100);
params.addRule(RelativeLayout.ALIGN_PARENT_TOP);
params.addRule(RelativeLayout.CENTER_HORIZONTAL);
rl.addView(balloon, params);
return true;
}

@Override
public boolean onItemLongPress(int index, OpenStreetMapViewOverlayItem item) {
// Toast.makeText(mapActivity, "Item '" + item.mTitle + "' (index=" + index + ") got long pressed", Toast.LENGTH_LONG).show();
return false;
}
});
}
synchronized void fillGMap(ProgressIndicator indicator, HashSet<MappableItem> mCurrentSubset) {
if (mCurrentSubset==null)
return;

Drawable defaultMarker = mapActivity.getResources().getDrawable(R.drawable.map_fave);
gmapListingsOverlay = new ListingsOverlay(mapActivity, gmap, defaultMarker);
MappableItem[] toMap = mCurrentSubset.toArray(new MappableItem[mCurrentSubset.size()]);
int listEnd=Math.min(toMap.length, Settings.GetMapMax());
for (int i=0; i<listEnd; i++) {
Drawable marker = toMap[i].getMarker(mapActivity);
ListingOverlayItem overlay = toMap[i].getListingOverlayItem();
if (overlay!=null)
gmapListingsOverlay.addItem(overlay, marker);
if (indicator!=null)
indicator.showProgress(i, listEnd);
}
gmapListingsOverlay.doPopulate();
}

public void showListingsOverlay() {
if (osmActive) {
List<OpenStreetMapViewOverlay> mapOverlays = osm.getOverlays();
if (!mapOverlays.contains(osmListingsOverlay))
mapOverlays.add(osmListingsOverlay);
if (!mapOverlays.contains(osmLocation))
mapOverlays.add(osmLocation);
}
else {
List<Overlay> mapOverlays = gmap.getOverlays();
if (!mapOverlays.contains(gmapListingsOverlay))
mapOverlays.add(gmapListingsOverlay);
if (!mapOverlays.contains(gmapLocation))
mapOverlays.add(gmapLocation);
}
postInvalidate();
}

public void postInvalidate() {
if (osmActive)
osm.postInvalidate();
else
gmap.postInvalidate();
}

public int getLatitudeSpan() {
if (osmActive)
return osm.getLatitudeSpanE6();
return gmap.getLatitudeSpan();
}
public int getLongitudeSpan() {
if (osmActive)
return osm.getLongitudeSpanE6();
return gmap.getLongitudeSpan();
}
public int getZoomLevel() {
if (osmActive)
return osm.getZoomLevel();
return gmap.getZoomLevel();
}

}


And how is it used? Well, the MapActivity includes code like this:

private void switchMapMode() {
int lastLat = mapView.getMapCenterLatE6();
int lastLon = mapView.getMapCenterLonE6();
int lastLatSpan = mapView.getLatitudeSpan();
int lastLonSpan = mapView.getLatitudeSpan();
boolean willHandleCenterZoom = lastLat!=0 && lastLon!=0 && lastLatSpan!=0 && lastLonSpan!=0;

if (mapView.isOSMActive())
useGMap(!willHandleCenterZoom);
else
useOSMMap(!willHandleCenterZoom);

if (willHandleCenterZoom)
mapView.centerAndZoom(lastLat, lastLon, lastLatSpan, lastLonSpan);
}
private void useGMap(boolean doCenterZoom) {
mapView.setOSMActive(false);
setContentView(R.layout.map);
//remove map view from old parent, if any
ITRGMapView gmap = mapView.getGMap();
RelativeLayout rl = (RelativeLayout) gmap.getParent();
if (rl!=null)
rl.removeView(gmap);
//add map view to new parent
rl = (RelativeLayout) findViewById(R.id.mapLayout);
rl.addView(mapView.getGMap());
setUpMap(doCenterZoom);
}
private void useOSMMap(boolean doCenterZoom) {
mapView.setOSMActive(true);
setContentView(R.layout.map_osm);
mapView.setOSMMap((ITROSMMapView)findViewById(R.id.mapview_osm));
setUpMap(doCenterZoom);
new FillMapTask().execute();
}
private void setUpMap(boolean doCenterZoom) {
registerForContextMenu(findViewById(R.id.tagButton));
registerForContextMenu(findViewById(R.id.mapButton));
setUpToolbar();
if (doCenterZoom && mCurrentLat!=0 && mCurrentLong!=0 && mCurrentLatSpan!=0 && mCurrentLongSpan!=0)
mapView.centerAndZoom(mCurrentLat, mCurrentLong, mCurrentLatSpan, mCurrentLongSpan);
else if (doCenterZoom && mapView.isOSMActive() && mCurrentLat==0 && mCurrentLong==0)
mapView.centerAndZoom(51000000, 0, 1000000, 1000000);
}


Of some interest, I suppose, are the BalloonLayout and ListingsOverlay classes mentioned above, so:


public class BalloonLayout extends LinearLayout {
private boolean pointerEnabled=true;
public void disablePointer() { pointerEnabled=false; }
public void enablePointer() {pointerEnabled=true; }

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

public BalloonLayout(Context context, AttributeSet set) {
super(context, set);
}

@Override
protected void dispatchDraw(Canvas canvas) {

Paint panelPaint = new Paint();
panelPaint.setARGB(0, 0, 0, 0);
RectF baloonRect = new RectF();
baloonRect.set(0,0, getMeasuredWidth(), 2*(getMeasuredHeight()/3));
panelPaint.setARGB(230, 255, 255, 255);
canvas.drawRoundRect(baloonRect, 10, 10, panelPaint);

if (pointerEnabled) {
Path baloonTip = new Path();
baloonTip.moveTo(5*(getMeasuredWidth()/8), 2*(getMeasuredHeight()/3));
baloonTip.lineTo(getMeasuredWidth()/2, getMeasuredHeight());
baloonTip.lineTo(3*(getMeasuredWidth()/4), 2*(getMeasuredHeight()/3));
canvas.drawPath(baloonTip, panelPaint);
}

super.dispatchDraw(canvas);
}

public void setUpTapFor(final Activity activity, String title, String snippet)
{
setVisibility(View.VISIBLE);
TextView titleText = (TextView) findViewById(R.id.detailText);
titleText.setText(snippet);

ImageButton detailButton = (ImageButton) findViewById(R.id.detailGo);
try {
final Long id = Long.valueOf(title);
detailButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) {
Intent intent = new Intent(activity, ListingActivity.class);
intent.putExtra(ListingActivity.KEY_ID, id);
activity.startActivityForResult(intent, 0);
//not pretty!
if (activity instanceof ITRMapActivity)
((ITRMapActivity)activity).editedListingID=id;
}
});
}
catch (NumberFormatException ex) {
ex.printStackTrace();
}

public static BalloonLayout GetBalloonFor(Activity activity) {
LayoutInflater layoutInflater = (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
final BalloonLayout balloon = (BalloonLayout) layoutInflater.inflate(R.layout.map_balloon, null);
RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(200,100);
layoutParams.addRule(RelativeLayout.CENTER_VERTICAL);
layoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL);
balloon.setLayoutParams(layoutParams);

ImageButton closeButton = (ImageButton) balloon.findViewById(R.id.detailClose);
closeButton.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) {
balloon.setVisibility(View.GONE);
}
});
return balloon;
}
}


although I have to work the kinks out of placing the balloon on the OSM map still. As for the ListingsOverlay,


public class ListingsOverlay extends ItemizedOverlay<ListingOverlayItem> {
private MapActivity activity;
private ITRGMapView mapView;
private BalloonLayout balloon;
private ArrayList<ListingOverlayItem> items=new ArrayList<ListingOverlayItem>();
public ArrayList<ListingOverlayItem> getItems() { return items; }

public ListingsOverlay(MapActivity anActivity, ITRGMapView aMapView, android.graphics.drawable.Drawable defaultMarker) {
super(defaultMarker);
activity=anActivity;
mapView=aMapView;
balloon = BalloonLayout.GetBalloonFor(activity);
}

public void addItem(ListingOverlayItem item, Drawable marker) {
if (item!=null && marker!=null && !items.contains(item)) {
super.boundCenterBottom(marker);
item.setMarker(marker);
items.add(item);
}
}

public void doPopulate() {
populate();
setLastFocusedIndex(-1);
}

@Override
protected ListingOverlayItem createItem(int i) {
return(items.get(i));
}

@Override
public int size() {
return(items.size());
}

@Override
protected boolean onTap(int i) {
mapView.removeView(balloon);
balloon.enablePointer();
ListingOverlayItem item = items.get(i);
balloon.setUpTapFor(activity, item.getTitle(), item.getSnippet());
MapView.LayoutParams params = new MapView.LayoutParams(200, 100, item.getPoint(), MapView.LayoutParams.BOTTOM_CENTER);
mapView.addView(balloon, params);
return true;
}

}



I hereby BSD 2.0-release all the above code, if anyone wants to use it - enjoy!

Labels: , , , , ,


This page is powered by Blogger. Isn't yours?

Subscribe to Posts [Atom]