When we first implemented map markers on our Android apps we were just using an image to highlight each location. Over time we wanted to add the specific price as well. As I started working on an implementation of that feature I was hard pressed to find any articles or examples of what we wanted to accomplish. So I had to start from scratch and figure out a method by myself. The initial implementation is currently in use on our Trulia Rentals android app. However, there are some deficiencies with that version and some of the code is sub-optimal. In this post I will detail the new method I am working on (which will be ported into all of our android apps).

Here are the pieces that we will need to make this custom marker happen:

  1. Activity– Yes, we need an activity to run our example app.
  2. MapView– We like to sub-class MapView and create a special custom version for Trulia.
  3. Overlay– We sub-class ItemizedOverlay for Trulia.
  4. OverlayItem– Using a custom OverlayItem we can add functionality to build our custom marker.
  5. Two drawablesfor the selected and unselected marker. We use 9 patch pngs in this example.
  6. Views — We have a main layout container which we inject our CustomMapView. We also have a custom view that we use for the marker itself.

View Layouts

main.xml — This is the view we use to setContentView() in our Activity

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/map_container"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical" />

marker.xml — This is the base layout for an individual marker used to create the custom marker

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:gravity="center" >

    <TextView
        android:id="@+id/text"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:background="@drawable/ic_map_marker_unviewed"
        android:paddingBottom="15sp"
        android:paddingLeft="10sp"
        android:paddingRight="10sp"
        android:paddingTop="10sp"
        android:textColor="@color/black"
        android:textSize="18dp" />

</FrameLayout>

Drawables

Here are the two drawables I am using for the selected and unselected state:

Unselected Marker
Unselected Drawable Marker

Selected Marker
Selected Drawable Marker

Custom OverlayItem

Now let’s start with the custom OverlayItem that we will use to build the custom marker.

class CustomItem extends OverlayItem {

	// text we want to overlay on top of our marker
	// for trulia this is going to be the price of the property
	private String label;

	// this is the marker we will send back to the Overlay to use when drawing
	private StateListDrawable marker = null;

	// this framelayout holds the view we will be turning into a drawable
	private FrameLayout markerLayout;

	// since we are going to use a StateListDrawable we will need to access
	// a few of the state values from android
	private int stateSelected = android.R.attr.state_selected;
	private int statePressed = android.R.attr.state_pressed;
	private int stateFocused = android.R.attr.state_focused;

	// we need a Context and Resource objects to pull in the marker drawables
	private Context context;
	private Resources res;

	// these are the resource ids for the two differnt marker states
	private static final int MARKER_DRAWABLE_UNVIEWED
										= R.drawable.ic_map_marker_unviewed;
	private static final int MARKER_DRAWABLE_SELECTED
										= R.drawable.ic_map_marker_selected;

	// Constructor which besides a GeoPoint, laben and snippet also gets pass
	// our
	// marker frame layout as well as the current application context
	CustomItem(GeoPoint pt, String label, String snippet,
			FrameLayout markerLayout, Context context) {
		super(pt, label, snippet);

		// set our class vars
		this.markerLayout = markerLayout;
		this.label = label;
		this.context = context;
		this.res = context.getResources();

		// now generate the state list drawable
		this.marker = generateStateListDrawable();
	}

	// main function where we create our Drawable by using the marker layout,
	// adding our string label, then converting to a bitmap drawable
	public Drawable generateMarker(boolean selected) {

		Bitmap viewCapture = null;
		Drawable drawOverlay = null;

		// make sure our marker layout isn't null
		if (markerLayout != null) {

			// set the text string into the view before we turn it into an image
			TextView textView = (TextView) markerLayout.findViewById(R.id.text);
			textView.setText(label);

			// calling setBackgroundResource seems to overwrite our padding in
			// the layout;
			// the following is a hack i found in this google bug report; fixes
			// gravity issue as well
			// http://code.google.com/p/android/issues/detail?id=17885
			int paddingTop = textView.getPaddingTop();
			int paddingLeft = textView.getPaddingLeft();
			int paddingRight = textView.getPaddingRight();
			int paddingBottom = textView.getPaddingBottom();

			// set the background bubble image based on whether its selected or
			// not
			if (selected) {
				textView.setBackgroundResource(MARKER_DRAWABLE_SELECTED);
			} else {
				textView.setBackgroundResource(MARKER_DRAWABLE_UNVIEWED);
			}

			// part of the hack above to reset the padding specified in the
			// marker layout
			textView.setPadding(paddingLeft, paddingTop, paddingRight,
					paddingBottom);

			// we need to enable the drawing cache
			markerLayout.setDrawingCacheEnabled(true);

			// this is the important code
			// Without it the view will have a dimension of 0,0 and the bitmap
			// will be null
			markerLayout.measure(
					MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
					MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
			markerLayout.layout(0, 0, markerLayout.getMeasuredWidth(),
					markerLayout.getMeasuredHeight());

			// we need to build our drawing cache
			markerLayout.buildDrawingCache(true);

			// not null? then we are ready to capture our bitmap image
			if (markerLayout.getDrawingCache() != null) {
				viewCapture = Bitmap.createBitmap(markerLayout
						.getDrawingCache());

				// if the view capture is not null we should turn off the
				// drawing cache
				// and then create our marker drawable with the view capture
				if (viewCapture != null) {
					markerLayout.setDrawingCacheEnabled(false);
					drawOverlay = new BitmapDrawable(viewCapture);
					return drawOverlay;
				}
			} else {
				Log.d("CustomMapMarkers",
						"Item * generateMarker *** getDrawingCache is null");
			}
		}
		Log.d("CustomMapMarkers", "Item * generateMarker *** returning null");
		return null;
	}

	/*
	 * (copied from the Google API docs) Sets the state of a drawable to match a
	 * given state bitset. This is done by converting the state bitset bits into
	 * a state set of R.attr.state_pressed, R.attr.state_selected and
	 * R.attr.state_focused attributes, and then calling {@link
	 * Drawable.setState(int[])}.
	 */
	public static void setState(final Drawable drawable, final int stateBitset) {
		final int[] states = new int[3];
		int index = 0;
		if ((stateBitset & ITEM_STATE_PRESSED_MASK) > 0)
			states[index++] = android.R.attr.state_pressed;
		if ((stateBitset & ITEM_STATE_SELECTED_MASK) > 0)
			states[index++] = android.R.attr.state_selected;
		if ((stateBitset & ITEM_STATE_FOCUSED_MASK) > 0)
			states[index++] = android.R.attr.state_focused;

		drawable.setState(states);
	}

	@Override
	public StateListDrawable getMarker(int stateBitset) {

		if (marker == null)
			return null;

		setState((Drawable) marker, stateBitset);
		marker.setBounds(0, 0, marker.getIntrinsicWidth(),
				marker.getIntrinsicHeight());
		return (marker);
	}

	public Drawable getDefaultMarker() {
		if (marker == null)
			marker = generateStateListDrawable();

		return marker;
	}

	// We create our statelist drawable by setting various states and the marker
	// to be displayed by that state;
	// the generateMarker() function takes in whether the drawable is selected
	// or not selected
	private StateListDrawable generateStateListDrawable() {
		StateListDrawable drawables = new StateListDrawable();
		drawables.addState(new int[] { stateSelected }, generateMarker(true));
		drawables.addState(new int[] { statePressed }, generateMarker(true));
		drawables.addState(new int[] { stateFocused }, generateMarker(true));
		drawables.addState(new int[] {}, generateMarker(false));
		return drawables;
	}
}

Custom Overlay

Our custom overlay class is very simple:

class CustomOverlay extends ItemizedOverlay<CustomItem> {

    protected ArrayList<CustomItem> mOverlays = new ArrayList<CustomItem>();

    public CustomOverlay(Drawable defaultMarker) {
        super(boundCenterBottom(defaultMarker));
    }

    @Override
    protected CustomItem createItem(int i) {
        return mOverlays.get(i);
    }

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

    public void addOverlay(CustomItem overlay) {
        mOverlays.add(overlay);
        populate();
    }

    public void addOverlay(CustomItem overlay, Drawable marker) {
        overlay.setMarker(boundCenterBottom(marker));
        addOverlay(overlay);
    }

    @Override
    public void draw(android.graphics.Canvas canvas, MapView mapView, boolean shadow) {
        super.draw(canvas, mapView, false);
    }
}

Custom MapView

And our custom MapView is also very simple:

public class CustomMapView extends MapView {

    private Context context;

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

    public CustomMapView(android.content.Context context,
                                   android.util.AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    public CustomMapView(android.content.Context context, java.lang.String apiKey) {
        super(context, apiKey);
    }

    public void setZoomControls() {
        getZoomButtonsController().setOnZoomListener(new OnZoomListener() {
            @Override
            public void onVisibilityChanged(boolean arg0) {
            }

            @Override
            public void onZoom(boolean zoomIn) {
            }
        });

    }
}

Activity

This is what our Activity looks like to pull it all together:

public class CustomMapMarkerActivity extends MapActivity {

    private static final String DEV_MAP_API_KEY = "{ADD_YOUR_OWN_MAP_KEY}";

    private LinearLayout mapContainer;
    private CustomMapView mapView;

    private static final int CENTER_LAT = (int) (37.76474 * 1E6);
    private static final int CENTER_LON = (int) (-122.44864 * 1E6);
    private static final GeoPoint CENTER_POINT = new GeoPoint(CENTER_LAT, CENTER_LON);

    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.main);

        mapContainer = (LinearLayout) findViewById(R.id.map_container);
        if (mapContainer.indexOfChild(mapView) == -1) {
            mapView = generateMapView();
            mapContainer.addView(mapView);
        }

        if (mapView != null) {
            List<Overlay> mapOverlays = mapView.getOverlays();
            FrameLayout markerLayout =
                      (FrameLayout) getLayoutInflater().inflate(R.layout.marker, null);
            CustomItem overlayItem =
                      new CustomItem(CENTER_POINT, "TEST", "",
                                                markerLayout, getApplicationContext());

            CustomOverlay itemizedOverlay =
                                new CustomOverlay(overlayItem.getDefaultMarker());
            itemizedOverlay.addOverlay(overlayItem);

            mapOverlays.add(itemizedOverlay);

            mapView.invalidate();
        }
    }

    /**
     * Generate the map view last location and zoom level set. called by
     * onCreate
     */
    private CustomMapView generateMapView() {
        if (mapView != null)
            return mapView;

        CustomMapView mapView = new CustomMapView(this, DEV_MAP_API_KEY);
        mapView.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT,
                                                  LayoutParams.WRAP_CONTENT));
        mapView.getController().setZoom(13);
        mapView.setBuiltInZoomControls(true);
        mapView.setClickable(true);
        mapView.setZoomControls();
        mapView.getController().animateTo(CENTER_POINT);

        return mapView;
    }

    @Override
    protected boolean isRouteDisplayed() {
        return false;
    }
}

SOURCE CODE: ZIP