Week 10 - Maps
Corresponding Text
Android Programming, pp. 571-584
The Maps API
Because of Android's dependence on Google's mapping services, the Android maps
functionality is not a a stand-alone component of Android. This requires us to
do several things before we can use maps in our app. First, we need to add a
dependency to our app. Just as we added the support and recyclerview libraries
to our app's dependencies, we'll have to add the Google Play Services. When
adding the library dependency, search for play-services-maps
and select the
com.google.android.gms:play-services-maps
library. If, after adding the
dependency, Android Studio is unable to display layouts in the design view, try
rebuilding the project.
The second thing we need to do is to grant our app permission to do certain
things: accessing the Internet to download map data, get the device's network
state, and to store temporary map data locally. To give add these permissions,
modify the app's manifest in app/Manifests/AndroidManifest.xml
so it appears
similar to the following:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.arthurneuman.mycontacts">
<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"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".ContactPagerActivity"
android:parentActivityName=".AddressBookActivity">
</activity>
<activity android:name=".AddressBookActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
Next, open a browser and load
https://developers.google.com/maps/documentation/android-api/signup
. On the
page, click GET A KEY, Select or create a project, and
Create a new project.
Enter the name of the project and click
Create and enable API. You should be presented with a new API key; copy
it. Finally, we can add the new key to the AndroidManifest.xml
file; the file
should look similar to the following (but with your key):
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.arthurneuman.mycontacts">
<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"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".ContactPagerActivity"
android:parentActivityName=".AddressBookActivity">
</activity>
<activity android:name=".AddressBookActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"/>
</application>
</manifest>
Now, we can add mapping functionality to our app.
Additional Contact Information
Our goal will be to display a map indicating the location of an address we
assign to each of our contacts. In order to assign an address, we'll need
to update the Contact class, ContactFragment, and the contact fragment
layout. To keep the example simple, our app will store a free-form address
like 550 E. Spring St, Columbus, OH 43215
.
In the Contact class, we can add a new field and create the corresponding getter and setter:
public class Contact {
...
private String mAddress;
...
public String getAddress() {
return mAddress;
}
public void setAddress(String address) {
mAddress = address;
}
}
Let's modify the AddressBook class to assign an address to all our contacts:
public class AddressBook {
...
private AddressBook() {
mContacts = new ArrayList<>();
for (int i=0; i<5; i++) {
Contact contact = new Contact();
contact.setName("Person " + i);
contact.setEmail("Person" + i + "@email.com");
contact.setAddress("550 E. Spring St, Columbus, OH 43215");
// set every 2nd as a favorite
if (i % 2 == 0) {
contact.setFavorite(true);
}
mContacts.add(contact);
}
}
...
}
Before we modify the layout, let's add a string resource to the strings.xml
resource file:
<string name="address_hint">Address</string>
To the layout, fragment_contact.xml
we can add another EditText widget with
the following properties:
Property | Value |
---|---|
ID | contact_address |
layout_width | match_parent |
layout_height | wrap_content |
InputType | textPostalAddress |
hint | @string/address_hint |
padding | 20dp |
The corresponding XML should look like this:
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPostalAddress"
android:id="@+id/contact_address"
android:padding="20dp"
android:hint="@string/address_hint"/>
Recall that we used TextWatcher to update instances of Contact when users typed values into the name and email fields. We can do that same thing for the address field.
public class ContactFragment extends Fragment {
...
private EditText mAddressField;
...
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
...
mAddressField = (EditText)v.findViewById(R.id.contact_address);
mAddressField.setText(mContact.getAddress());
mAddressField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start,
int count, int after) { }
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
mContact.setAddress(s.toString());
}
@Override
public void afterTextChanged(Editable s) { }
});
...
}
}
Using Maps
As a next step, let's add a MapView to our fragment_contact.xml
layout;
drag and drop a MapView from the design palette to an area below the address
EditText. Set the MapView's id to contact_map
. The layout's XML should
look similar to the following:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.arthurneuman.mycontacts.FavoriteView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/contact_favorite"/>
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/contact_name"
android:hint="@string/name_hint"
android:padding="20dp"
android:inputType="textPersonName"/>
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/contact_email"
android:hint="@string/email_hint"
android:padding="20dp"
android:inputType="textEmailAddress"/>
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPostalAddress"
android:id="@+id/contact_address"
android:padding="20dp"
android:hint="@string/address_hint"/>
<com.google.android.gms.maps.MapView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/contact_map" />
</LinearLayout>
Next, we can begin to work with our map by modifying the ContactFragment class. Before we add any code, it's important that we understand how maps work. We can access the MapView widget in the same way we access the other widgets using View.findViewById(). Once we have access to the widget, we have to forward all lifecycle calls from our fragment to the widget; this includes calls to the following:
- onCreate()
- onResume()
- onPause()
- onDestroy()
- onSaveInstanceState()
- onLowMemory()
We can override the ContactFragment's methods to call the corresponding widget's methods; we won't have access to the widget until onCreateView() is called in ContactFragment so we can call the widget's onCreate() method there.
After we call onCreate(), we have to get an instance of a GoogleMap object that will allow us to display and interact with a Google map. To do this, we use the widget's getMapAsync() method. This method takes one parameter: an object that implements the OnMapReadyCallback interface. We can implement the interface using an anonymous class.
In order to display the address, we will need to convert it to a latitude and a longitude. To do this, we will rely on a Geocoder. The Geocoder is able to find latitude and longitude from a given address and return a list of results. We can use the first result to create a map marker and reposition the map to the latitude and longitude. We use the clear() method to remove any existing markers - this will be useful when we update the map when the address changes.
With these changes, ContactFragment should include the following:
public class ContactFragment extends Fragment implements OnMapReadyCallback {
...
private MapView mMapView;
...
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
...
mMapView = (MapView)v.findViewById(R.id.contact_map);
mMapView.onCreate(savedInstanceState);
mMapView.getMapAsync(new OnMapReadyCallback() {
@Override
public void onMapReady(GoogleMap googleMap) {
googleMap.clear();
Geocoder geo = new Geocoder(getContext());
try {
List<Address> addresses =
geo.getFromLocationName(mAddressField.getText().toString(), 1);
if (addresses.size() > 0) {
LatLng latLng = new LatLng(addresses.get(0).getLatitude(),
addresses.get(0).getLongitude());
MarkerOptions marker = new MarkerOptions().position(latLng);
googleMap.addMarker(marker);
googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 15));
}
} catch (IOException e) {
}
}
});
...
}
...
@Override
public void onResume() {
super.onResume();
mMapView.onResume();
}
@Override
public void onPause() {
super.onPause();
mMapView.onPause();
}
@Override
public void onDestroy() {
super.onDestroy();
mMapView.onDestroy();
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
mMapView.onSaveInstanceState(outState);
}
@Override
public void onLowMemory() {
super.onLowMemory();
mMapView.onLowMemory();
}
}
When we run the app and select a contact we should now see something similar to the following:
If you are running the code on a device and the map does not appear to be correct, try rebooting the device.
Finally, let's update the code to update the map if the text in the address field changes.
public class ContactFragment extends Fragment {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
...
mAddressField.setOnFocusChangeListener(new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View view, boolean b) {
if (!b) {
updateMap();
}
}
});
mMapView = (MapView)v.findViewById(R.id.contact_map);
mMapView.onCreate(savedInstanceState);
updateMap();
return v;
}
private void updateMap() {
mMapView.getMapAsync(new OnMapReadyCallback() {
@Override
public void onMapReady(GoogleMap googleMap) {
googleMap.clear();
Geocoder geo = new Geocoder(getContext());
try {
List<Address> addresses =
geo.getFromLocationName(mAddressField.getText().toString(), 1);
if (addresses.size() > 0) {
LatLng latLng = new LatLng(addresses.get(0).getLatitude(),
addresses.get(0).getLongitude());
MarkerOptions marker = new MarkerOptions().position(latLng);
googleMap.addMarker(marker);
googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 15));
}
} catch (IOException e) {
}
}
});
}
...
}
Here, we've pulled the code to update the map into its own method,
updateMap(), and called this new method from the onCreateView() method.
We've also assigned an OnFocusChangeListener listener to the address field.
This method is called when the field is being used by the user or when the
user stops using it to begin using some other widget. In the onFocusChange()
method, we check to see if the widget is losing focus and then update the map -
this allows the app to wait until the user has finished entering test before
attempting to update the map. An alternative would be to update the map using
one of the TextWatcher methods but unless we controlled how often the map
could update, the app would attempt to update the map with every keystroke.
For clarity, all the code in the ContactFragment class is presented below.
public class ContactFragment extends Fragment {
private Contact mContact;
private EditText mNameField;
private EditText mEmailField;
private FavoriteView mFavoriteView;
private EditText mAddressField;
private MapView mMapView;
private static final String ARG_CONTACT_ID = "contact_id";
public static ContactFragment newInstance(UUID contactID) {
ContactFragment contactFragment = new ContactFragment();
Bundle args = new Bundle();
args.putSerializable(ARG_CONTACT_ID, contactID);
contactFragment.setArguments(args);
return contactFragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
UUID contactID = (UUID) getArguments().getSerializable(ARG_CONTACT_ID);
mContact = AddressBook.get().getContact(contactID);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View v = inflater.inflate(R.layout.fragment_contact, container, false);
mNameField = (EditText)v.findViewById(R.id.contact_name);
mNameField.setText(mContact.getName());
mNameField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count,
int after) {
// No new behavior
}
@Override
public void onTextChanged(CharSequence s, int start, int before,
int count) {
mContact.setName(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
// No new behavior
}
});
mEmailField = (EditText)v.findViewById(R.id.contact_email);
mEmailField.setText(mContact.getEmail());
mEmailField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count,
int after) {
// No new behavior
}
@Override
public void onTextChanged(CharSequence s, int start, int before,
int count) {
mContact.setEmail(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
// No new behavior
}
});
mFavoriteView = (FavoriteView) v.findViewById(R.id.contact_favorite);
mFavoriteView.setSelected(mContact.isFavorite());
mFavoriteView.setOnSelectedChangedListener(new FavoriteView.OnSelectedChangedListener() {
@Override
public void onSelectedChanged(boolean selected) {
mContact.setFavorite(selected);
}
});
mAddressField = (EditText)v.findViewById(R.id.contact_address);
mAddressField.setText(mContact.getAddress());
mAddressField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start,
int count, int after) { }
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
mContact.setAddress(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
mAddressField.setOnFocusChangeListener(new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View view, boolean b) {
if (!b) {
updateMap();
}
}
});
mMapView = (MapView)v.findViewById(R.id.contact_map);
mMapView.onCreate(savedInstanceState);
updateMap();
return v;
}
private void updateMap() {
mMapView.getMapAsync(new OnMapReadyCallback() {
@Override
public void onMapReady(GoogleMap googleMap) {
googleMap.clear();
Geocoder geo = new Geocoder(getContext());
try {
List<Address> addresses =
geo.getFromLocationName(mAddressField.getText().toString(), 1);
if (addresses.size() > 0) {
LatLng latLng = new LatLng(addresses.get(0).getLatitude(),
addresses.get(0).getLongitude());
MarkerOptions marker = new MarkerOptions().position(latLng);
googleMap.addMarker(marker);
googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 15));
}
} catch (IOException e) {
}
}
});
}
@Override
public void onResume() {
super.onResume();
mMapView.onResume();
}
@Override
public void onPause() {
super.onPause();
mMapView.onPause();
}
@Override
public void onDestroy() {
super.onDestroy();
mMapView.onDestroy();
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
mMapView.onSaveInstanceState(outState);
}
@Override
public void onLowMemory() {
super.onLowMemory();
mMapView.onLowMemory();
}
}