15.
Google Places
Written by Namrata Bandekar
Before you can achieve the ultimate goal of allowing users to bookmark places, you need to let them identify existing places on the map.
In this chapter, you’ll learn how to identify when a user taps on a place and use the Google Places API to retrieve detailed information about the place.
Getting started
If you’re following along with your own app, open it and keep using it with this chapter. If not, don’t worry. Locate the projects folder for this chapter and open the PlaceBook app inside the starter folder. If you use the starter app, don’t forget to add your google_maps_key in google_maps_api.xml. Read Chapter 13 for more details about the Google Maps key.
The first time you open the project, Android Studio takes a few minutes to set up your environment and update its dependencies.
If you’re following along with your own app, you’ll also need to copy default_photo.png from src/main/res/drawable-xxx, which is included with the starter project, into your project.
Make sure to copy the files from all of the drawable folders (hdpi,mdpi,xhdpi,xxhdpi).
Before using the Google Places API, you need to take care of a bit of housekeeping first by enabling the Places API in the developer console and adding the Places API dependency.
Note: The Google screens in this book might be slightly different than what you see on the Google developer portal since Google changes these often.
Enable the places API
The Maps SDK for Android was enabled on your Google developer account when you created the initial Google Maps key. However, you need to turn on the Google Places API manually.
Log into your Google developer account at https://console.developers.google.com.
Ensure the project containing the Maps API key you created previously is selected. Switch to the Library tab on the left.
Click on Places API.
You’ll see the following screen with an Enable button:
Click on ENABLE and wait while Google enables the API. After the API is enabled, the screen changes to show your app’s metrics for this API.
Click on the hamburger menu and navigate back to the main Dashboard.
Scroll down to see the APIs for your app. Both the Maps SDK for Android and Places API are listed.
One last thing. To use the Places API, you must enable billing on each of your projects that use the SDK. To do this sign up for billing at https://console.cloud.google.com/projectselector2/billing/enable.
Places API overview
The Google Places API provides a wealth of capabilities all related to — wait for it — working with places on a map! A place is anything that can be identified on a map, such as a household, a business, or a public park. Google places gives you access to over 200 million places stored in the main Google Maps database.
Although referred to as a single API, you generally interact with the Places API through several sub-APIs.
Note: Google Places for Android enforces a limit on the number of requests per month. You can see the details of the pricing model at https://cloud.google.com/maps-platform/pricing. To prevent your app from failing when it exceeds these limits, follow the instructions in the usage limits guide at https://developers.google.com/places/android-sdk/usage-and-billing.
Add the Places API dependency
Just like the location API, you’ll have to add the Places API dependency yourself.
Open app/build.gradle and add the places library to the dependencies section as follows:
implementation "com.google.android.libraries.places:places:2.4.0"
This instructs the Gradle build system to include the Places API in your build.
Selecting points of interest
You may have noticed icons with place names scattered throughout the map. These are called points of interest, or POIs, and they will let the user look up details about each place. You’ll begin by making the POIs a little more interesting by allowing the user to interact with them.
The Google Map object has convenient, built-in capabilities to let you know when the user taps on a POI. You need to set up a POI click listener and wait for the user to tap away.
Like all other interactions with the map object, you’ll wait to set up the listener until OnMapReady() is called. Open MapsActivity.kt and add the following to the end of onMapReady():
map.setOnPoiClickListener {
Toast.makeText(this, it.name, Toast.LENGTH_LONG).show()
}
Here, you call setOnPoiClickListener() on map and provide it a lambda that implements the single onPoiClick() method of the PoiClickListener interface.
The map object will call your lambda anytime it detects that the user has tapped on a POI. The lambda is passed in a single parameter of type PointOfInterest that you access through the implicit it variable.
PointOfInterest contains only three properties:
-
latLng: The geographic location of the selected POI is represented by a latitude and longitude in decimal degrees. -
name: The name of the POI. This normally matches what’s shown on the map. -
placeId: A string that uniquely identifies the POI. You can use theplaceIdto retrieve aPlaceobject from the Places API.
Run the app and tap on a few places. You’ll see toast messages pop up with the name of each place you tap:
Load place details
Now that you have the placeId when a user taps a POI, you can use it to look up more details about the place. The goal is to provide the user with a quick popup info window, from which they can decide if they want to bookmark the place.
To retrieve the details for places, you’ll use the Places SDK.
Before using the Places SDK, you need to create a PlacesClient. This client is your gateway to all of the available APIs provided by Places API.
In MapsActivity.kt, add the following import statements to import the places client library and the PlacesClient.
import com.google.android.libraries.places.api.Places
import com.google.android.libraries.places.api.net.PlacesClient
Next, add a new private member below the map member:
private lateinit var placesClient: PlacesClient
Add the following method to MapsActivity under onMapReady():
private fun setupPlacesClient() {
Places.initialize(applicationContext, getString(R.string.google_maps_key))
placesClient = Places.createClient(this)
}
This creates the PlacesClient. Every API call to the Places API must contain your API key. You’ll notice that you’re passing the application context to initialize the Places library. You also pass the current activity context to create the PlacesClient.
Now, add the following at the end of onCreate() to ensure PlacesClient gets initialized only once when the activity is created.
setupPlacesClient()
Next, you want to use PlacesClient to fetch details about a place. Add the following method to MapsActivity.kt:
private fun displayPoi(pointOfInterest: PointOfInterest) {
// 1
val placeId = pointOfInterest.placeId
// 2
val placeFields = listOf(Place.Field.ID,
Place.Field.NAME,
Place.Field.PHONE_NUMBER,
Place.Field.PHOTO_METADATAS,
Place.Field.ADDRESS,
Place.Field.LAT_LNG)
// 3
val request = FetchPlaceRequest
.builder(placeId, placeFields)
.build()
// 4
placesClient.fetchPlace(request)
.addOnSuccessListener { response ->
// 5
val place = response.place
Toast.makeText(this,
"${place.name}, " +
"${place.phoneNumber}",
Toast.LENGTH_LONG).show()
}.addOnFailureListener { exception ->
// 6
if (exception is ApiException) {
val statusCode = exception.statusCode
Log.e(TAG,
"Place not found: " +
exception.message + ", " +
"statusCode: " + statusCode)
}
}
}
Let’s break this down:
-
First, you retrieve the
placeIdwhich uniquely identifies your place of interest. -
Next, you create a field mask that contains only the attributes of a place you are interested in retrieving. This ensures that you only request data that you use and keeps your app’s network usage under control. Notice that you’re requesting a bunch of fields that you’ll use in the latter part of this chapter.
-
You then use these two objects to create a fetch request. You use the familiar builder pattern to create this request.
-
Then, you fetch the place details using
placesClient, which handles yourrequest. -
You add a success listener which is called if the response is successfully received. You then retrieve the
placeobject which contains the requested details. You display the name and phone number for the selected place on the screen. -
You also add a failure listener which catches any exception that could occur in the case the request fails. More specifically, you may want to know if there was an API error that occurred. You also log the status code and message to use for debugging the error.
Note: Many of the Places API methods like
fetchPlace()make network calls, and can take a long time to return. For this reason, the places library offloads these tasks to the background and returns aTask. The network call completes asynchronously and then calls either yourOnSuccessListenerorOnFailureListenerwith a callback method.
Now, update setOnPoiClickListener() to call this new method. In onMapReady(), replace the call to map.setOnPoiClickListener() with the following:
map.setOnPoiClickListener {
displayPoi(it)
}
This calls displayPoi() when a place on the map is tapped.
Build and run the app and tap on a few more places. This time, you’ll see the place name and its phone number, if one is available.
Note: If you don’t see the Toast pop up, check the Logcat for error messages. If you see
Place not found: 9010: You have exceeded your daily request quota for this API, then check that you have enabled billing for your project in the developer console.
You have a lot of details about the place, but wouldn’t it be nice to also show a photo?
Getting a photo is not as simple as getting the basic place details, but armed with your newfound knowledge of result callbacks, you’re up to the task!
You’ll use the same callback pattern to get a photo for the selected place with a separate call to fetchPhoto. Once you retrieve the place in your success callback for fetchPlace, you can use the requested PHOTO_METADATAS field in the place object to create a FetchPhotoRequest object and subsequently make another call to the placesClient. As you can see, this code can quickly become deeply nested and messy. To avoid this, you’re going to do a bit of clean-up!
Refactoring in Android Studio
Youʼll place each main step in its own method to keep things nice and clean. You start by refactoring displayPoi() to kick off the first step. You take the code inside of displayPoi() and move it into a new method that takes a single argument. You then add a call to the new method inside displayPoi(). This is a common refactoring step that Android Studio can automate for you.
Instead of manually cutting and pasting or typing in the method call, try this:
-
Select all of the code inside
displayPoi(). -
Press Cmd+Option+M on macOS or Ctrl-Alt-M on Windows to initiate the Extract Function command.
-
Type in the name of the new method:
displayPoiGetPlaceStep. Look at the preview window and notice that Android Studio is smart enough to add thepointOfInterestparameter that it knows you’ll need in the new method. -
Click OK.
Voilà! The method is created, and the call is added to displayPoi().
Your refactored code looks like this:
private fun displayPoi(pointOfInterest: PointOfInterest) {
displayPoiGetPlaceStep(pointOfInterest)
}
private fun displayPoiGetPlaceStep(pointOfInterest: PointOfInterest) {
val placeId = pointOfInterest.placeId
val placeFields = listOf(Place.Field.ID,
Place.Field.NAME,
Place.Field.PHONE_NUMBER,
Place.Field.PHOTO_METADATAS,
Place.Field.ADDRESS,
Place.Field.LAT_LNG)
val request = FetchPlaceRequest
.builder(placeId, placeFields)
.build()
placesClient.fetchPlace(request)
.addOnSuccessListener { response ->
val place = response.place
Toast.makeText(this,
"${place.name}, " +
"${place.phoneNumber}",
Toast.LENGTH_LONG).show()
}.addOnFailureListener { exception ->
if (exception is ApiException) {
val statusCode = exception.statusCode
Log.e(TAG,
"Place not found: " +
exception.message + ", " +
"statusCode: " + statusCode)
}
}
}
Fetching a place photo
Now, you’ll add a step to retrieve a photo using the place details you requested in the previous step.
Add the following new method to MapsActivity:
private fun displayPoiGetPhotoStep(place: Place) {
// 1
val photoMetadata = place
.getPhotoMetadatas()?.get(0)
// 2
if (photoMetadata == null) {
// Next step here
return
}
// 3
val photoRequest = FetchPhotoRequest
.builder(photoMetadata)
.setMaxWidth(resources.getDimensionPixelSize(
R.dimen.default_image_width))
.setMaxHeight(resources.getDimensionPixelSize(
R.dimen.default_image_height))
.build()
// 4
placesClient.fetchPhoto(photoRequest)
.addOnSuccessListener { fetchPhotoResponse ->
val bitmap = fetchPhotoResponse.bitmap
// Next step here
}.addOnFailureListener { exception ->
if (exception is ApiException) {
val statusCode = exception.statusCode
Log.e(TAG,
"Place not found: " +
exception.message + ", " +
"statusCode: " + statusCode)
}
}
}
You use the following steps to get a photo for the selected place:
- Get the first and only
PhotoMetaDataobject from the retrieved photo metadata array for the selected place. - If there’s no photo for the place, skip directly to the next step.
- Then, you use the builder pattern again to create the
FetchPhotoRequest. You pass the builder thephotoMetaData, a maximum width and a maximum height for the retrieved image. - You call
fetchPhotopassing in thephotoRequestand let the callbacks handle the response from the Place Photos service. If the response is successfully received, assign the photo tobitmap. Otherwise, check if anApiExceptionoccurred and log an error.
In displayPoiGetPhotoStep(), you pass a maximum height and width to get a scaled-down version of the original photo. The image is scaled proportionally to match the smaller of the two dimensions.
There are two benefits to restricting the image height and width in the photoRequest.
-
Memory savings: In general, you never want to load photos into memory that are larger than required. Here, you limit the possibility of memory issues that can happen on lower-end devices.
-
Bandwidth savings: Since
photoRequestsends the maximum height and width in the API call, the scaling happens on the server-side and only the final scaled-down version is sent to the device.
Now, replace the Toast call in displayPoiGetPlaceStep() with a call to this new method:
displayPoiGetPhotoStep(place)
Next, fix the unresolved references for R.dimen.default_image_width and R.dimen.default_image_height that are displayed in red. To resolve the errors, follow these steps:
-
Place the cursor on
default_image_widthand press Alt-Return, then select Create a dimen value resource ‘default_image_width’. -
In the dialog that appears, set Resource Value to 480px and leave the other values at their defaults. Click OK.
-
Place the cursor on
default_image_heightand type Alt-Return, then select Create a dimen value resource ‘default_image_height’. -
In the dialog that appears, set Resource Value to 270px and leave the other values at their defaults. Click OK.
This creates two values in res/values/dimens.xml for the default image width and height. You’ll see these values pop up again as you build out the app.
Note: You could have used two hard-coded numbers for the width and height parameters to
getScaledPhoto(), but these are considered “magic” numbers in the code and should always be avoided.Placing them in dimens.xml is two steps closer to coding nirvana: You gain reuse and follow the DRY principle with a single location for updating the values, as well as built-in documentation for the types of values that the numbers represent.
Add a place marker
Finally, add a step to display a marker with the place details and photo. Add the following new method to MapsActivity:
private fun displayPoiDisplayStep(place: Place, photo: Bitmap?) {
val iconPhoto = if (photo == null) {
BitmapDescriptorFactory.defaultMarker()
} else {
BitmapDescriptorFactory.fromBitmap(photo)
}
map.addMarker(MarkerOptions()
.position(place.latLng as LatLng)
.icon(iconPhoto)
.title(place.name)
.snippet(place.phoneNumber)
)
}
If photo is null, you create iconPhoto as a default marker bitmap. If it’s not null, you create iconPhoto from the photo. Next, add a marker to the map by creating a new MarkerOptions object and setting the properties to the place details and the iconPhoto.
Using markers will be covered in more detail soon, but for now, it’s enough to know that addMarker() places a persistent marker on the map represented by an icon. The default marker icon is a red balloon pin but can be replaced with any bitmap image. Markers will respond to user taps and display an info window with more details.
Replace the first commented line // Next step here in displayPoiGetPhotoStep() to call your new step:
displayPoiDisplayStep(place, null)
Here, you pass along the place object and a null bitmap image.
Replace the second commented line // Next step here in displayPoiGetPhotoStep() to call your new step:
displayPoiDisplayStep(place, bitmap)
Here, you pass along the place object and the bitmap image bitmap.
Run the app and tap on some places. You should see place photos appear on the map. Tap on a photo to display an info window with the place name and phone number.
Custom info window
Now you’re making some progress! The user can tap places to view a photo and details, but having large photos all over the map is a little unwieldy. A better experience would be to display a standard marker next to each place and only show the photo and details in a popup info window.
By default, tapping on a marker displays a standard info window. This window looks like the following:
The standard info window will display the title and snippet as defined on the marker. If you want to display additional information, a custom info window is in order.
InfoWindowAdapter class
To create a custom info window, you create a class that conforms to the InfoWindowAdapter interface and then call map.setInfoWindowAdapter() with an instance of the class.
There are two methods to implement in InfoWindowAdapter:
-
getInfoWindow(): This one allows you to return a custom view for the full info window. -
getInfoContents(): This allows you to return a custom view for the interior contents of the info window only without changing the default outer window and background.
In your case, only the info window contents will be replaced. Before creating a custom info window, you need to create a layout file for the contents. The layout will look like this:
Create a new layout resource file named res/layout/content_bookmark_info.xml with the following contents:
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="5dp">
<ImageView
android:id="@+id/photo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:adjustViewBounds="true"
android:maxWidth="200dp"
android:scaleType="fitStart"
android:contentDescription="@string/bookmark_image"
android:src="@drawable/default_photo"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:textColor="#ff000000"
android:textSize="14sp"
android:textStyle="bold"
tools:text="Place Title"/>
<TextView
android:id="@+id/phone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#ff7f7f7f"
android:textSize="12sp"
tools:text="555-121-1212"/>
</LinearLayout>
</LinearLayout>
You use a horizontal LinearLayout to wrap the place image and another vertical LinearLayout for the place details. You’ll load this Layout from InfoWindowAdapter and populate the ImageView and both TextViews.
Open res/values/strings.xml and add the following content description string for the bookmark image view:
<string name="bookmark_image">Bookmark image</string>
Before you jump into creating the class that conforms to the InfoWindowAdapter interface, you will add View Binding to Placebook. Using View Binding will help you to easily write code that interacts with views in your app and make the compilation of your app faster. Each XML layout file gets a corresponding automatically generated binding class, which has references to all views with an id in the layout file.
Open app/build.gradle and add the following below kotlinOptions:
buildFeatures {
viewBinding true
}
Now, sync the project so Android Studio registers the change in your gradle file. This generates a binding class called ContentBookmarkInfoBinding, which is basically the name of the XML layout file in Pascal case with the Binding suffix. ContentBookmarkInfoBinding contains references to the root view, the top-level Linear Layout you added in content_bookmark_info.xml, and all views that have an id.
Now, create a new package named adapter and then a new Kotlin class named BookmarkInfoWindowAdapter.kt within the adapter package.
BookmarkInfoWindowAdapter is your custom InfoWindowAdapter.
Replace the contents of BookmarkInfoWindowAdapter.kt with the following:
// 1
package com.raywenderlich.placebook.adapter
import com.raywenderlich.placebook.databinding.ContentBookmarkInfoBinding
// 2
class BookmarkInfoWindowAdapter(context: Activity) : GoogleMap.InfoWindowAdapter {
// 3
private val binding = ContentBookmarkInfoBinding.inflate(context.layoutInflater)
// 4
override fun getInfoWindow(marker: Marker): View? {
// This function is required, but can return null if
// not replacing the entire info window
return null
}
// 5
override fun getInfoContents(marker: Marker): View? {
binding.title.text = marker.title ?: ""
binding.phone.text = marker.snippet ?: ""
return binding.root
}
}
Here’s what’s happening:
-
You declare the package and add an import for the auto-generated binding class
ContentBookmarkInfoBinding. -
You declare
BookmarkInfoWindowAdapterto take a single parameter representing the hosting activity. The class implements theGoogleMap.InfoWindowAdapterinterface. -
You initialize the variable
bindingby calling the staticinflatemethod which creates an instance of the binding class. -
You override
getInfoWindow()and returnnullto indicate that you won’t be replacing the entire info window. -
You override
getInfoContents()and fill in thetitleandphoneTextViews on the Layout.
Once this object is assigned, the map will call getInfoWindow() whenever it needs to display an info window for a particular marker.
Note that you’re not providing an image for the ImageView at this point. The only information you’re given in getInfoContents() is the associated Marker, and it doesn’t store the photo. This will be fixed soon, but for now, you’ll continue to hook up the window adapter.
Assigning the InfoWindowAdapter
In MapsActivity.kt, add the following line to onMapReady() after map is assigned:
map.setInfoWindowAdapter(BookmarkInfoWindowAdapter(this))
Here, you assign your custom InfoWindowAdapter to map.
You no longer need to set the photo as the marker icon. In displayPoiDisplayStep(), remove the lines that create the iconPhoto variable. Then remove setIcon() from MarkerOptions. The entire body of displayPoiDisplayStep() should look like this:
map.addMarker(MarkerOptions()
.position(place.latLng as LatLng)
.title(place.name)
.snippet(place.phoneNumber)
)
Run the app and tap on any place. A default red balloon marker will be added. Tap on the marker to display the info window.
You’ll finish off the BookmarkInfoWindowAdapter by adding the place image.
Marker tags
So, how do you associate the image with the marker? There are several ways to tackle this problem, but they all involve using the tag property of the Marker object.
Marker provides the tag property as a means to associate the marker with data you are managing in the app. This could be a simple index into a list or dictionary, a full complex object, or in this case, a Bitmap object.
In displayPoiDisplayStep(), replace the call to addMarker() with this:
val marker = map.addMarker(MarkerOptions()
.position(place.latLng as LatLng)
.title(place.name)
.snippet(place.phoneNumber)
)
marker?.tag = photo
Here, addMarker() returns a Marker object and you assign it to marker. You then assign photo to the tag property.
Next, add the following lines to getInfoContents() in BookmarkInfoWindowAdapter.kt before the return line:
val imageView = binding.photo
imageView.setImageBitmap((marker.tag as Bitmap))
Since you assigned the place’s image bitmap with the marker’s tag property, when the map draws the info window contents it can set the ImageView to display the photo.
Run the app and tap on a place and the marker. This time the place photo will display in the info window.
Key Points
Maps are only useful when they offer the right amount of information. Google Places API can help you to enrich your map-based app with information about places. In this chapter you learned:
- How to set up Places API in your Google console account.
- How to add Places API to your app and use it for selecting points of interest from your map.
- How to retrieve information about a specific point of interest using Places API.
- How to display information on your map using InfoWindowAdapter.
Where to go from here?
Pat yourself on the back for making it this far! You have everything you need to move on to the bookmarking feature.
In the next chapter, you’ll learn how to save places to a local database and let the user edit place details.