Android Indoor Maps

By Paulus, 31 July, 2014

Back in May of this year, Google released an update to their Play Services. Up until then, it wasn't possible to use all the features of indoor maps found in Google Maps for Android. Strangely, iOS has had this feature for some time now. One of the those features was being able to manipulate the floor picker. To start using indoor maps properly, you will need to create a new project and configure it to use Google Play Services. The guide can be found here. Since the indoor maps is an added feature to Google Maps, there isn't a lot of additional coding that needs to be done. I've created a simple mobile application that takes advantage of the new Indoor API functions.

The Manifest is pretty self-explanatory and while the layout file is also self explanatory, I would like to explain the spinner found in the layout. Having a spinner, which controls the active floor is redundant, I only added it to demonstrate how to manipulate the map.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.paulusworld.androidindoormaps"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk
        android:minSdkVersion="19"
        android:targetSdkVersion="19" />

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="com.google.android.providers.gsf.permission.READ_GSERVICES" />
    <!--
			The following two permissions are not required to use
			Google Maps Android API v2, but are recommended.
    -->
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

    <uses-feature
        android:glEsVersion="0x00020000"
        android:required="true" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >

        <meta-data
            android:name="com.google.android.gms.version"
            android:value="@integer/google_play_services_version" />
        <meta-data
            android:name="com.google.android.maps.v2.API_KEY"
            android:value="THIS_IS_WHERE_YOU_PUT_YOUR_API_KEY" />

        <activity
            android:name=".MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="com.paulusworld.androidindoormaps.MainActivity" >

    <fragment
        android:id="@+id/map"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_weight="45"
        class="com.google.android.gms.maps.SupportMapFragment" />

    <TextView
        android:id="@+id/textCoordinates"
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        android:layout_weight="5"
        android:text="@string/btn_load_floors"
        android:textAlignment="center"
        android:textAppearance="?android:attr/textAppearanceSmall"
        android:textIsSelectable="true" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_weight="5"
        android:orientation="horizontal" >

        <Spinner
            android:id="@+id/spinnerFloors"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="70" />

    </LinearLayout>

</LinearLayout>
package com.paulusworld.androidindoormaps;

import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.GoogleMap.OnCameraChangeListener;
import com.google.android.gms.maps.GoogleMap.OnIndoorStateChangeListener;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.CameraPosition;
import com.google.android.gms.maps.model.IndoorBuilding;
import com.google.android.gms.maps.model.IndoorLevel;
import com.google.android.gms.maps.model.LatLng;

import android.content.Context;
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
import android.view.View;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemSelectedListener;
import android.widget.ArrayAdapter;
import android.widget.Spinner;

public class MainActivity extends FragmentActivity {

	private Context mContext;
	private GoogleMap mMap;
	private Spinner mSpinnerFloors;
	private IndoorBuilding mBuilding;
	private ArrayAdapter<String> mBuildingFloors;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		mContext = this.getBaseContext();

		mSpinnerFloors = (Spinner) findViewById(R.id.spinnerFloors);
		mSpinnerFloors.setOnItemSelectedListener(new OnItemSelectedListener() {

			@Override
			public void onItemSelected(AdapterView<?> parent, View view,
					int position, long id) {
				try {
					mMap.getFocusedBuilding().getLevels().get(position)
							.activate();
				} catch (NullPointerException npe) {
					// In a production application, we would do something with
					// the exception.
				}

			}

			@Override
			public void onNothingSelected(AdapterView<?> parent) {

			}

		});

	}

	@Override
	protected void onResume() {
		super.onResume();
		setupMapIfNeeded();
	}

	private void setupMapIfNeeded() {
		if (mMap == null) {
			mMap = ((SupportMapFragment) getSupportFragmentManager()
					.findFragmentById(R.id.map)).getMap();
			if (mMap != null) {
				setupMap();
			}
		}
	}

	private void setupMap() {
		// Pick a location that has indoor maps and zoom in far enough to show
		// the floor plans.
		mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(43.040715,
				-87.921467), 18));

		// Add a listener for when the indoor state changes.
		mMap.setOnIndoorStateChangeListener(new OnIndoorStateChangeListener() {

			@Override
			public void onIndoorBuildingFocused() {
				if (mBuilding != mMap.getFocusedBuilding()) {

					mBuilding = mMap.getFocusedBuilding();
					mBuildingFloors = getBuildingFloors(mBuilding);
					mSpinnerFloors.setAdapter(mBuildingFloors);

				}
			}

			@Override
			public void onIndoorLevelActivated(IndoorBuilding building) {

				mSpinnerFloors.setSelection(building.getActiveLevelIndex());

			}

		});

		mMap.setOnCameraChangeListener(new OnCameraChangeListener() {

			private float mCurrentZoom = -1;

			@Override
			public void onCameraChange(CameraPosition position) {
				try {
					if (position.zoom != mCurrentZoom) {

						mCurrentZoom = position.zoom;

						if (mMap.getFocusedBuilding() == null) {

							mBuildingFloors.clear();
							mBuildingFloors.notifyDataSetChanged();

						}
					}
				} catch (NullPointerException npe) {
					// In a production application, we would do something with
					// the exception.
				}
			}
		});
	}

	private ArrayAdapter<String> getBuildingFloors(IndoorBuilding indoorBuilding) {

		ArrayAdapter<String> floors = new ArrayAdapter<String>(mContext,
				android.R.layout.simple_spinner_dropdown_item);

		if (indoorBuilding != null) {
			indoorBuilding.getLevels()
					.get(indoorBuilding.getDefaultLevelIndex()).activate();

			for (IndoorLevel level : indoorBuilding.getLevels()) {
				floors.add(level.getName());
			}
		}

		return floors;
	}
}

Lines 31 - 59 set up the interface. On line 37, we're adding an OnItemSelectedListener to the spinner. The listener has two methods, but we're only using the onItemSelected. The onItemSelected is asking the map if there is a focused building. If there is an active building, get the levels and set the active level of the building to the same level the user selected from the spinner control. 

Line 80 moves the camera to a predefined location and zooms in. Indoor maps are only visible at certain levels. Therefore, to display the floor picker, we're setting the zoom level to 18. 

Line 84 adds a OnIndoorStateChangeListener to the map object. This listener has two methods, onIndoorBuildingFocused and onIndoorLevelActivated. The onIndoorBuildingFocused is looking to see if the building has changed. if it has, then we want to get the floors for the new building and populate them in the spinner. 

Line 100 updates the spinner whenever the user selects a different floor from the floor picker. 

Lines 106 - 130 is a feature in which spinner is cleared if the user zooms out far enough, ultimately losing focus of the building. 

getBuildingFloors which is defined on line 132 is a helper function. The function takes a IndoorBuilding object as a parameter, which is used to build an ArrayAdapter containing the names of the floors in the building.