18.
Navigation & Photos
Written by Kevin D Moore
In this chapter, you’ll add the ability to navigate directly to bookmarks, and you’ll also replace the photo for a bookmark.
Getting started
The starter project for this chapter includes an additional icon that you need to complete the chapter. You can either begin this chapter with the starter project or copy src/main/res/drawable-xxxx/ic_other.png from the starter project into yours.
Make sure to copy the files from all of the drawable folders, including everything with the .hdpi, .mdpi, .xhdpi, .xxhdpi and .xxxhdpi extensions.
If you do 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.
Bookmark navigation
At the moment, the only way to find an existing bookmark is to locate its pin on the map. Let’s save a little skin on the user’s fingertips by creating a Navigation Drawer that they can use to jump directly to any bookmark.
Navigation drawer design
While the navigation drawer is going out of fashion, it’s difficult to use Android without encountering a navigation drawer. Although its uses vary, they share a common design pattern. The drawer is hidden to the left of the main content view and is activated with either a swipe from the left edge of the screen or by tapping a navigation drawer icon. Once the drawer is activated, it slides out over the top of the main content and slides back in once an action has been taken by the user.
You can add a navigation drawer in three steps:
- Make
DrawerLayoutthe root view of the Layout. - Make the first view within
DrawerLayoutthe main content. - Make the second view within
DrawerLayoutthe navigation drawer content.
The final navigation drawer will look like this:
Navigation drawer layout
To create the drawer Layout, you need to create a new Layout file for the navigation drawer, move the map fragment from activity_maps.xml to its own Layout file, and update activity_maps.xml to contain the DrawerLayout element.
First, you need to move the map fragment to a separate Layout.
Create a new Layout resource file in res/layout, and name it main_view_maps.xml. Then, replace the contents with 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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.raywenderlich.placebook.ui.MapsActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/AppTheme.AppBarOverlay">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:popupTheme="@style/AppTheme.PopupOverlay" />
</com.google.android.material.appbar.AppBarLayout>
<fragment
android:id="@+id/map"
android:name="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
This file will be included in activity_maps.xml. A root LinearLayout is defined to hold a standard action bar just like the one you created for the detail Activity. The action bar is required to hold the navigation drawer toggle icon.
You’ll eventually add code in MapsActivity.kt to dynamically create the navigation drawer toggle icon for the action bar.
Next, you need a Layout to define the navigation drawer.
Create a new Layout resource file in res/layout, and name it drawer_view_maps.xml. Then, replace the contents with the following:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawerView"
android:layout_width="240dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="#ddd"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="140dp"
android:background="@color/colorAccent"
android:gravity="bottom"
android:orientation="vertical"
android:paddingBottom="10dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="10dp"
android:theme="@style/ThemeOverlay.AppCompat.Dark">
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="10dp"
app:srcCompat="@mipmap/ic_launcher_round" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:text="PlaceBook"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="raywenderlich.com" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/bookmarkRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</LinearLayout>
This Layout defines the contents of the navigation drawer. There are a few important elements:
- The main
layout_widthis set to240dp. This is a safe width that ensures some of the underlying views will be visible when the drawer is fully open. For mobile devices, the maximum size recommended by the design guidelines is 280dp. - The main Layout specifies a
layout_gravityof “start” instead of “left”. This places the drawer on the right side of the screen if the user’s language is RTL (right-to-left). - The Layout defines a top header area used to display the app icon and some basic application information.
- The area below the header contains a
RecyclerView. This view is used to display the list of stored bookmarks.
Now, you need a Layout for each bookmark item that will be shown in the navigation drawer.
Create a new Layout resource file in res/layout, and name it bookmark_item.xml. Then, replace the contents with the following:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="bookmarkData"
type="com.raywenderlich.placebook.viewmodel.MapsViewModel.BookmarkMarkerView" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="10dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="10dp">
<ImageView
android:id="@+id/bookmarkIcon"
android:layout_width="30dp"
android:layout_height="30dp"
android:adjustViewBounds="true"
android:scaleType="fitStart"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/bookmarkNameTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="16dp"
android:text="@{bookmarkData.name}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/bookmarkIcon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Name" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
This defines the Layout for a single bookmark entry in the RecyclerView. You define a simple Layout with a bookmark category icon on the left and the bookmark title on the right.
That completes the new Layout files you need for the navigation drawer. Next, you need to update the main maps Activity to use the new Layouts. Open activity_maps.xml and replace the contents with the following:
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:openDrawer="start">
<include android:id="@+id/mainMapView" layout="@layout/main_view_maps"/>
<include android:id="@+id/drawerViewMaps" layout="@layout/drawer_view_maps"/>
</androidx.drawerlayout.widget.DrawerLayout>
The main Activity Layout previously contained a single map Fragment that filled the entire screen. Now it has a root DrawerLayout that includes the main_view_maps and the drawer_view_maps.
To make the navigation drawer and action bar work properly, you need to do a few more things.
Open AndroidManifest.xml and update the MapsActivity <activity> entry by adding android:theme="@style/AppTheme.NoActionBar" to match the following:
<activity
android:name=".ui.MapsActivity"
android:label="@string/title_activity_maps"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
The only change is to add the AppTheme.NoActionBar theme style. This is standard procedure when using the support library version of the toolbar as the action bar.
Adding Data Binding to MapActivity
Since activity_maps.xml used the layout tag, you need to update MapsActivity to use Data Binding. Open up MapsActivity.kt and add the databinding variable after the mapsViewModel:
private lateinit var databinding: ActivityMapsBinding
Import this class and then replace the setContentView() call in the onCreate method with:
databinding = ActivityMapsBinding.inflate(layoutInflater)
setContentView(databinding.root)
The final piece is to enable the support toolbar in MapsActivity. Open MapsActivity.kt and add the following method to MapsActivity:
private fun setupToolbar() {
setSupportActionBar(databinding.mainMapView.toolbar)
}
Again, this is the standard setup code that’s required when using the support library version of the toolbar as the action bar.
You need to call setupToolbar() when the Activity is created.
Add the following lines before setupPlacesClient() in onCreate():
setupToolbar()
Build and run the app. Swipe right starting on the left edge of the screen; the navigation drawer slides out.
To close it, swipe left on the navigation drawer.
Navigation toolbar toggle
Add a toggle button for the navigation drawer by creating an ActionBarDrawerToggle. This is used to integrate the drawer functionality with the app bar.
The constructor for ActionBarDrawerToggle requires two string resources for the open and closed drawer states.
Add the following lines to res/values/strings.xml:
<string name="open_drawer">Open Drawer</string>
<string name="close_drawer">Close Drawer</string>
Then add the following to the end of setupToolbar() in MapsActivity.kt:
val toggle = ActionBarDrawerToggle(
this, databinding.drawerLayout, databinding.mainMapView.toolbar,
R.string.open_drawer, R.string.close_drawer)
toggle.syncState()
The ActionBarDrawerToggle takes your drawerLayout and toolbar and fully manages the display and functionality of the toggle icon. The last two arguments set the content descriptor on the action bar based on the navigation drawer state. You call toggle.syncState to ensure the toggle icon is displayed initially.
Build and run the app. Tap the toggle (hamburger) icon to test the navigation drawer slide.
Populating the navigation bar
To populate the navigation bar, you need to provide an Adapter to the RecyclerView and use LiveData to update the Adapter any time bookmarks change in the database.
The Adapter requires some view data — one option is to create a new data class in MapsViewModel. You already have the BookmarkMarkerView class used by the MapsActivity for the map markers, so you can take advantage of the existing class and the code that observes changes to the data.
Since you’ll be using BookmarkMarkerView to display markers and the navigation drawer items, it needs a more generic name. This is a great opportunity to use Android Studio’s available refactoring capabilities.
Open MapsViewModel.kt and locate the BookmarkMarkerView declaration. Right-click on the word BookmarkMarkerView, and then select Refactor ▸ Rename… or place the cursor on BookmarkMarkerView and press Shift-F6.
BookmarkMarkerView is highlighted. Change the name to BookmarkView and press Enter.
This automatically updates all references to use BookmarkView instead of BookmarkMarkerView. This is a great feature that can save a lot of time when renaming classes, methods, or variables.
Note: When performing a refactor, there might be an additional step in some cases. If you notice the ‘Refactoring Preview’ window appears on the bottom left of Android Studio, you will need to review the changes, and if they match your intent, click ‘Do Refactor’ to perform the operation.
Use the same rename refactor to change the following:
-
getBookmarkMarkerViews()⇢getBookmarkViews(). -
mapBookmarksToMarkerView()⇢mapBookmarksToBookmarkView(). -
bookmarkToMarkerView()⇢bookmarkToBookmarkView().
In MapsActivity.kt, use the rename feature to change createBookmarkMarkerObserver() to createBookmarkObserver().
Now back to the task at hand. To populate the recycler view in the navigation drawer, you’ll need to create a new recycler view adapter class.
Create a new Kotlin class in the adapter package and name it BookmarkListAdapter.kt. Now, replace the contents with the following:
// 1
class BookmarkListAdapter(
private var bookmarkData: List<BookmarkView>?,
private val mapsActivity: MapsActivity
) : RecyclerView.Adapter<BookmarkListAdapter.ViewHolder>() {
// 2
class ViewHolder(
val binding: BookmarkItemBinding,
private val mapsActivity: MapsActivity
) : RecyclerView.ViewHolder(binding.root) {
}
// 3
fun setBookmarkData(bookmarks: List<BookmarkView>) {
this.bookmarkData = bookmarks
notifyDataSetChanged()
}
// 4
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = BookmarkItemBinding.inflate(layoutInflater, parent, false)
return ViewHolder(binding, mapsActivity)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
// 5
bookmarkData?.let { list->
// 6
val bookmarkViewData = list[position]
// 7
holder.binding.root.tag = bookmarkViewData
holder.binding.bookmarkData = bookmarkViewData
holder.binding.bookmarkIcon.setImageResource(R.drawable.ic_other)
}
}
// 8
override fun getItemCount() = bookmarkData?.size ?: 0
}
BookmarkListAdapter is a standard RecyclerView Adapter that you learned about in Chapter 7, RecyclerViews.
Note: Android Studio may not import the View class automatically. If it doesn’t, add
import android.view.Viewto the top of the file.
Here there is a breakdown of the previous code:
- The Adapter constructor takes two arguments: a list of
BookmarkViewitems and a reference to theMapsActivity. Both arguments are defined as class properties. - A
ViewHolderclass is defined to hold the view widgets. -
setBookmarkDatais designed to be called when the bookmark data changes. It assignsbookmarksto the newBookmarkViewList and refreshes theRecyclerViewby callingnotifyDataSetChanged(). -
onCreateVieHolderis overridden and used to create aViewHolderby inflating thebookmark_itemlayout and passing in themapsActivityproperty. - You make sure
bookmarkDatais not null before doing the binding. -
bookmarkViewDatais assigned to the bookmark data for the current itemposition. - A reference to the
bookmarkViewDatais assigned to the holder’sitemView.tag, and theViewHolderitems are populated from thebookmarkViewData. For now, a default icon is used to represent the bookmark category. -
getItemCount()is overridden to return the number of items in thebookmarkDatalist.
You can now use the Adapter in the maps Activity. Open MapsActivity.kt and add the following property to MapsActivity:
private lateinit var bookmarkListAdapter: BookmarkListAdapter
Add the following method to MapsActivity:
private fun setupNavigationDrawer() {
val layoutManager = LinearLayoutManager(this)
databinding.drawerViewMaps.bookmarkRecyclerView.layoutManager = layoutManager
bookmarkListAdapter = BookmarkListAdapter(null, this)
databinding.drawerViewMaps.bookmarkRecyclerView.adapter = bookmarkListAdapter
}
This method sets up the adapter for the bookmark recycler view. It gets the RecyclerView from the Layout, sets a default LinearLayoutManager for the RecyclerView, then creates a new BookmarkListAdapter and assigns it to the RecyclerView.
You’ll need to set up the navigation drawer at the time the Activity is created. Add the following line to the end of onCreate():
setupNavigationDrawer()
Also, you need to make sure the list Adapter is updated any time the list of bookmarks changes. This can be handled in createBookmarkObserver().
Add the following line to createBookmarkObserver(), after the call to displayAllBookmarks(it):
bookmarkListAdapter.setBookmarkData(it)
This sets the new list of BookmarkView items on the recycler view adapter whenever the bookmark data changes. This causes the navigation drawer items to update and reflect the current state of the database.
Build and run the app. Make sure you have some bookmarks saved and then open the navigation drawer. You’ll see it populated with the list of bookmark names. Add a new bookmark, and the navigation drawer should update to reflect the addition.
Navigation bar selections
It’s great that users can now see a list of bookmark names, but it’s not very functional. It’s time to add the ability to zoom to a bookmark when the user taps an item in the navigation drawer.
First, you need to add a method that centers the map on a bookmark marker and opens the marker’s Info window.
Before writing this method, you need a way to get a handle on a map marker for a given bookmark instance. Unfortunately, there’s no direct way to get a list of all markers managed by the GoogleMap object — you’ll have to take matters into your own hands!
An easy way to manage the markers is to use a HashMap that associates bookmark IDs to map markers.
Open MapsActivity.kt and add the following property:
private var markers = HashMap<Long, Marker>()
This creates and initializes a HashMap to map a bookmark ID (Long) to a Marker.
Add the following line before the return in addPlaceMarker():
bookmark.id?.let { markers.put(it, marker) }
This adds a new entry to markers when a new marker is added to the map.
In createBookmarkObserver(), add the following line after the call to map.clear():
markers.clear()
This clears markers when the bookmark data changes. markers are populated again when all of the bookmarks are added to the map.
You’ll also need a way to update the map to the location of a bookmark.
Start by adding a helper method to zoom the map to a specific location.
Add the following method to MapsActivity:
private fun updateMapToLocation(location: Location) {
val latLng = LatLng(location.latitude, location.longitude)
map.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, 16.0f))
}
This pans and zooms the map to center over a Location. A LatLng is created from the Location and is used to create the CameraUpdate object for animateCamera(). animateCamera() is similar to the moveCamera() method that you used before, but it smoothly pans the map instead of abruptly jumping to the new location.
With that in place, you can now make a new method that moves the map to a bookmark location.
Finally, add the following method to MapsActivity:
fun moveToBookmark(bookmark: MapsViewModel.BookmarkView) {
// 1
databinding.drawerLayout.closeDrawer(databinding.drawerViewMaps.drawerView)
// 2
val marker = markers[bookmark.id]
// 3
marker?.showInfoWindow()
// 4
val location = Location("")
location.latitude = bookmark.location.latitude
location.longitude = bookmark.location.longitude
updateMapToLocation(location)
}
Here’s how it works:
- Before zooming the bookmark, the navigation drawer is closed.
- The
markersHashMapis used to look up theMarker. - If the marker is found, its Info window is shown.
- A
Locationobject is created from the bookmark, andupdateMapToLocation()is called to zoom the map to the bookmark.
The final step is to call moveToBookmark() when the user taps on a bookmark. This is handled by the bookmark list adapter class.
Open BookmarkListAdapter.kt and add the following method to the ViewHolder class:
init {
binding.root.setOnClickListener {
val bookmarkView = itemView.tag as BookmarkView
mapsActivity.moveToBookmark(bookmarkView)
}
}
This method is called when a ViewHolder is initialized. It sets an onClickListener on the ViewHolder. When the click event is fired, you get the bookmarkView associated with the ViewHolder and call moveToBookmark() to zoom the map to the bookmark.
Before wrapping up this feature, you need to add one simple change to sort the bookmarks by name. The simplest place to do this is in the bookmark data access object.
Open BookmarkDao.kt and update the @Query attribute on loadAll() to match the following:
@Query("SELECT * FROM Bookmark ORDER BY name")
Build and run the app. Open the navigation drawer and notice how the bookmarks are now sorted by name.
Tap on a bookmark item; the navigation drawer closes, and the map zooms to the selected bookmark with its Info window already open.
Custom photos
While Google provides a default photo for each place, your users may prefer to use that perfect selfie instead. In this section, you’ll add the ability to replace the place photo with one from the photo library or one you take on the fly with the camera.
Image option dialog
You’ll start by creating a dialog to let the user choose between an existing image or capturing a new one.
Create a new Kotlin file inside ui, and name it PhotoOptionDialogFragment.kt. Then, set the contents as follows:
class PhotoOptionDialogFragment : DialogFragment() {
// 1
interface PhotoOptionDialogListener {
fun onCaptureClick()
fun onPickClick()
}
// 2
private lateinit var listener: PhotoOptionDialogListener
// 3
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
// 4
listener = activity as PhotoOptionDialogListener
// 5
var captureSelectIdx = -1
var pickSelectIdx = -1
// 6
val options = ArrayList<String>()
// 7
val context = activity as Context
// 8
if (canCapture(context)) {
options.add("Camera")
captureSelectIdx = 0
}
// 9
if (canPick(context)) {
options.add("Gallery")
pickSelectIdx = if (captureSelectIdx == 0) 1 else 0
}
// 10
return AlertDialog.Builder(context)
.setTitle("Photo Option")
.setItems(options.toTypedArray<CharSequence>()) { _, which ->
if (which == captureSelectIdx) {
// 11
listener.onCaptureClick()
} else if (which == pickSelectIdx) {
// 12
listener.onPickClick()
}
}
.setNegativeButton("Cancel", null)
.create()
}
companion object {
// 13
fun canPick(context: Context) : Boolean {
val pickIntent = Intent(Intent.ACTION_PICK,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
return (pickIntent.resolveActivity(
context.packageManager) != null)
}
// 14
fun canCapture(context: Context) : Boolean {
val captureIntent = Intent(
MediaStore.ACTION_IMAGE_CAPTURE)
return (captureIntent.resolveActivity(
context.packageManager) != null)
}
// 15
fun newInstance(context: Context) =
// 16
if (canPick(context) || canCapture(context)) {
PhotoOptionDialogFragment()
} else {
null
}
}
}
Note: Make sure to import the
androidx.fragment.app.DialogFragmentandandroidx.appcompat.app.AlertDialogwhen given options for imports.
This class defines a dialog fragment that shows an AlertDialog with one or two options based on the device capabilities. If the device can select images from the gallery, then a Gallery option is included. If the device has a camera to capture new images, then a Camera option is included.
-
The class defines an interface that must be implemented by the parent Activity. You’ll implement this interface in
BookmarkDetailsActivity. -
A property is defined to hold an instance of
PhotoOptionDialogListener. -
This is the standard
onCreateDialogmethod for aDialogFragment. -
The listener property is set to the parent Activity.
-
The two possible option indices are initialized to -1. The option indices are defined dynamically, because the position of the Gallery and Camera options may change based on the device capabilities.
-
An options
ArrayListis defined to hold theAlertDialogoptions. -
The next few calls require a
Contextobject. You’ll use theactivityproperty of theAlertDialog()class as the context. Since theactivityproperty has a getter method and may change between calls, you set a temporary un-mutable local variable and use it to prevent compiler errors. -
If the device has a camera capable of capturing images, then a Camera option is added to the options array. The
captureSelectIdxvariable is set to 0 to indicate the Camera option will be at position 0 in the option list. -
If the device can pick an image from a gallery, then a Gallery option is added to the options array. The
pickSelectIdxvariable is set to 0 if it’s the first option, or to 1 if it’s the second option. -
The
AlertDialogis built using the options list, and anonClickListeneris provided to respond to the user selection. -
If the Camera option was selected, then
onCaptureClick()is called onlistener. -
If the Gallery option was selected, then
onPickClick()is called onlistener. -
canPick()determines if the device can pick an image from a gallery. It determines this by creating an intent for picking images, and then it checks to see if the Intent can be resolved. This is a standard method for detecting if a particular Intent option is possible on the current device. -
canCapture()determines if the device has a camera to capture a new image. It uses the same technique ascanPick()but with a different Intent action. -
newInstanceis a helper method intended to be used by the parent activity when creating a newPhotoOptionDialogFragment. -
If the device can pick from a gallery or snap a new image, then the
PhotoOptionDialogFragmentis created and returned, otherwisenullis returned.
Open BookmarkDetailsActivity.kt and update the class declaration as follows so that it implements the PhotoOptionDialogListener interface:
class BookmarkDetailsActivity : AppCompatActivity(), PhotoOptionDialogFragment.PhotoOptionDialogListener {
This causes an error until you implement the PhotoOptionDialogListener interface.
Add the following methods:
override fun onCaptureClick() {
Toast.makeText(this, "Camera Capture", Toast.LENGTH_SHORT).show()
}
override fun onPickClick() {
Toast.makeText(this, "Gallery Pick", Toast.LENGTH_SHORT).show()
}
You’ll soon implement the code to snap a photo or pick one from the gallery, but for now, they’re just placeholders.
Now you can add a method that creates the photo option dialog and displays it to the user.
While in BookmarkDetailsActivity.kt, add the following method:
private fun replaceImage() {
val newFragment = PhotoOptionDialogFragment.newInstance(this)
newFragment?.show(supportFragmentManager, "photoOptionDialog")
}
When the user taps on the bookmark image, you call replaceImage(). This attempts to create the PhotoOptionDialogFragment fragment. If newFragment is not null, then it’s displayed.
All that’s left is to listen for the imageViewPlace to be tapped and call replaceImage().
Add the following code at the end of populateImageView():
databinding.imageViewPlace.setOnClickListener {
replaceImage()
}
This sets a click listener on imageViewPlace and calls replaceImage() when the image is tapped.
Managing package visibility
On Android 11 checking for apps like the camera or the gallery require new manifest changes. A new queries tag is now available in the manifest file. Open up AndroidManifest.xml and at the bottom before </manifest> add:
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<action android:name="android.intent.action.PICK" />
<data android:mimeType="image/*" />
</intent>
</queries>
This allows the app to request a camera or gallery type app to take a picture or choose an existing photo.
Note: Check out the following Google documentation for further information about package visibility in Android 11 https://developer.android.com/training/basics/intents/package-visibility.
Build and run the app. Bring up the details for a bookmark and tap the photo. The options dialog will display. Tap on one of the options and the appropriate toast should be displayed.
Now, you’re ready to implement the code to capture or pick the image. You’ll start with the capture option.
Capturing an image
Capturing a full-size image from Android consists of the following steps:
- Create a unique filename to store the captured image.
- Create an Intent with the
MediaStore.ACTION_IMAGE_CAPTUREaction. - Add the Uri to the unique filename as an extra on the Intent.
- Invoke the Intent using
startActivityForResult. - Respond to the Activity result, and process the captured image, which is located at the filename Uri you provided.
Generate a unique filename
First, you need to create a helper method to generate a unique image filename.
Open ImageUtils.kt and add the following method:
@Throws(IOException::class)
fun createUniqueImageFile(context: Context): File {
val timeStamp = SimpleDateFormat("yyyyMMddHHmmss").format(Date())
val filename = "PlaceBook_" + timeStamp + "_"
val filesDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile(filename, ".jpg", filesDir)
}
Note: Make sure to use
import java.text.SimpleDateFormatforSimpleDateFormatandjave.util.DateforDate.
This method returns an empty File in the app’s private pictures folder using a unique filename. The filename is created by using the current timestamp with “PlaceBook_” prepended.
The method is flagged with @Throws to account for File.createTempFile() possibly throwing an IOException.
Next, you need to add a property to the details Activity to keep track of the image File.
Open BookmarkDetailsActivity.kt and add the following property:
private var photoFile: File? = null
This is used to hold a reference to the temporary image file when capturing an image.
Start the capture activity
Before you can call the image capture Activity, you need to define a request code. This can be any number you choose. It will be used to identify the request when the image capture activity returns the image.
You can define this request code as a constant value in a companion object.
Add the following internal companion object to the bottom of BookmarkDetailsActivity:
companion object {
private const val REQUEST_CAPTURE_IMAGE = 1
}
This defines the request code to use when processing the camera capture Intent. Now it’s time to replace the temporary onCaptureClick() method with one that captures an image.
Replace the contents of onCaptureClick() with the following:
// 1
photoFile = null
try {
// 2
photoFile = ImageUtils.createUniqueImageFile(this)
} catch (ex: java.io.IOException) {
// 3
return
}
// 4
photoFile?.let { photoFile ->
// 5
val photoUri = FileProvider.getUriForFile(this,
"com.raywenderlich.placebook.fileprovider",
photoFile)
// 6
val captureIntent = Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE)
// 7
captureIntent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, photoUri)
// 8
val intentActivities = packageManager.queryIntentActivities(
captureIntent, PackageManager.MATCH_DEFAULT_ONLY)
intentActivities.map { it.activityInfo.packageName }
.forEach { grantUriPermission(it, photoUri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION) }
// 9
startActivityForResult(captureIntent, REQUEST_CAPTURE_IMAGE)
}
Here’s the code breakdown:
- Any previously assigned
photoFileis cleared. - You call
createUniqueImageFile()to create a uniquely named image File and assign it tophotoFile. - If an exception is thrown, the method returns without doing anything.
- You use the
?.letto make surephotoFileis notnullbefore continuing with the rest of the method. -
FileProvider.getUriForFile()is called to get a Uri for the temporary photo file. - A new Intent is created with the
ACTION_IMAGE_CAPTUREaction. This Intent is used to display the camera viewfinder and allow the user to snap a new photo. - The
photoUriis added as an extra on the Intent, so the Intent knows where to save the full-size image captured by the user. - Temporary write permissions on the
photoUriare given to the Intent. - The Intent is invoked, and the request code
REQUEST_CAPTURE_IMAGEis passed in.
Note:
FileProviderworks by creating a content:// Uri for a file versus a file:// Uri. This is important to allow granting of temporary access permissions to read and write files. You can read more aboutFileProviderand why it is more secure than using file:// Uris by going to https://developer.android.com/reference/android/support/v4/content/FileProvider.html.
Using a FileProvider requires that it be registered in the AndroidManifest.xml file.
Register the FileProvider
Open AndroidManifest.xml and add the following to the <application> section:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.raywenderlich.placebook.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
This declares your FileProvider with the authority of com.raywenderlich.placebook.fileprovider. You can choose any unique name here; by convention, this should start with your app’s package name. Notice that it matches the name used when calling FileProvider.getUriForFile().
The FileProvider references an XML resource file that defines the allowed file paths. Android flags this as an error, but you’ll create the resource file now to resolve the error.
Select File ▸ New ▸ Android resource file and set the File name to file_paths and the Resource type to XML. The directory name should change to xml. Do not worry about the “Root element” value. Tap OK.
This creates a new res directory named xml containing the new file_paths.xml file.
Now, you can fill in the file_paths.xml with the allowed file paths.
Replace the contents of file_paths.xml with the following:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path
name="placebook_images"
path="Android/data/com.raywenderlich.placebook/files/Pictures" />
</paths>
This defines a single path to the Pictures directory within the PlaceBook file container.
Build and run the app. Tap the photo on a bookmark photo, then select the Camera option. Verify that the device camera is activated and you can snap a photo.
The photo won’t update when the camera view is closed because you haven’t written the code to process the capture Intent results yet.
Process the capture results
The images captured from the camera can be much larger than what’s needed to display in the app. As part of the processing of the newly captured photo, you’ll downsample the photo to match the default bookmark photo size. This calls for some new methods in the ImageUtils.kt class.
Open ImageUtils.kt and add the following private method:
private fun calculateInSampleSize(
width: Int,
height: Int,
reqWidth: Int,
reqHeight: Int
): Int {
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while (halfHeight / inSampleSize >= reqHeight &&
halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
This method is used to calculate the optimum inSampleSize that can be used to resize an image to a specified width and height. The inSampleSize must be specified as a power of two. This method starts with an inSampleSize of 1 (no downsampling), and it increases the inSampleSize by a power of two until it reaches a value that will cause the image to be downsampled to no larger than the requested image width and height.
Now that you can calculate the proper sample size for any width and height, a new method can be added to decode a file. This method is called when an image needs to be downsampled.
Add the following method:
fun decodeFileToSize(
filePath: String,
width: Int,
height: Int
): Bitmap {
// 1
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(filePath, options)
// 2
options.inSampleSize = calculateInSampleSize(
options.outWidth, options.outHeight, width, height)
// 3
options.inJustDecodeBounds = false
// 4
return BitmapFactory.decodeFile(filePath, options)
}
This method is called by BookmarkDetailsActivity to get the downsampled image with a specific width and height from the captured photo file.
- The size of the image is loaded using
BitmapFactory.decodeFile(). TheinJustDecodeBoundssetting tellsBitmapFactoryto not load the actual image, just its size. -
calculateInSampleSize()is called with the image width and height and the requestedwidthandheight.optionsis updated with the resultinginSampleSize. -
inJustDecodeBoundsis set to false to load the full image this time. -
BitmapFactory.decodeFile()loads the downsampled image from the file returns it.
Many times when a user takes a photo in portrait mode, the image will come in rotated. To fix that create a rotation method that will rotate the bitmap to look correct if rotated incorrectly.
Add the following method:
private fun rotateImage(img: Bitmap, degree: Float): Bitmap? {
val matrix = Matrix()
matrix.postRotate(degree)
val rotatedImg = Bitmap.createBitmap(img, 0, 0, img.width, img.height, matrix, true)
img.recycle()
return rotatedImg
}
Note: Choose the
android.graphics.Matriximport.
This method will create a new bitmap and rotate it by the given degrees.
Now add a method that will check the File meta data to check it’s orientation:
@Throws(IOException::class)
fun rotateImageIfRequired(context: Context, img: Bitmap, selectedImage: Uri): Bitmap {
val input: InputStream? = context.contentResolver.openInputStream(selectedImage)
val path = selectedImage.path
val ei: ExifInterface = when {
Build.VERSION.SDK_INT > 23 && input != null -> ExifInterface(input)
path != null -> ExifInterface(path)
else -> null
} ?: return img
return when (ei.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(img, 90.0f) ?: img
ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(img, 180.0f) ?: img
ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(img, 270.0f) ?: img
else -> img
}
}
This method gets the orientation in the Exif tags of a JPEG file and calls rotateImage if it is not already 0 degrees.
The BookmarkView class now needs a new method to replace the image for a bookmark.
Add the following method to BookmarkDetailsView class in BookmarkDetailsViewModel.kt:
fun setImage(context: Context, image: Bitmap) {
id?.let {
ImageUtils.saveBitmapToFile(context, image,
Bookmark.generateImageFilename(it))
}
}
This takes in a Bitmap image and saves it to the associated image file for the current BookmarkView.
Now that BookmarkView can replace its own image, you need to create a method in the details Activity to replace the image in the imageViewPlace control and update the bookmark view object.
Open BookmarkDetailsActivity.kt and add the following method:
private fun updateImage(image: Bitmap) {
bookmarkDetailsView?.let {
databinding.imageViewPlace.setImageBitmap(image)
it.setImage(this, image)
}
}
This method assigns an image to the imageViewPlace and saves it to the bookmark image file using bookmarkDetailsView.setImage().
To read in and process the image captured by the system, you need a method that takes a file path and returns the downsized image as a Bitmap.
Add the following method to BookmarkDetailsActivity:
private fun getImageWithPath(filePath: String) = ImageUtils.decodeFileToSize(
filePath,
resources.getDimensionPixelSize(R.dimen.default_image_width),
resources.getDimensionPixelSize(R.dimen.default_image_height)
)
This method uses the new decodeFileSize method to load the downsampled image and return it.
With all of the supporting code in place, you’re ready to process the camera results.
Add the following method:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// 1
if (resultCode == android.app.Activity.RESULT_OK) {
// 2
when (requestCode) {
// 3
REQUEST_CAPTURE_IMAGE -> {
// 4
val photoFile = photoFile ?: return
// 5
val uri = FileProvider.getUriForFile(this,
"com.raywenderlich.placebook.fileprovider",
photoFile)
revokeUriPermission(uri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
// 6
val image = getImageWithPath(photoFile.absolutePath)
val bitmap = ImageUtils.rotateImageIfRequired(this, image , uri)
updateImage(bitmap)
}
}
}
}
onActivityResult() is called by Android when an Activity returns a result such as the Camera capture activity.
- First, the
resultCodeis checked to make sure the user didn’t cancel the photo capture. - The
requestCodeis checked to see which call is returning a result. - If the
requestCodematchesREQUEST_CAPTURE_IMAGE, then processing continues. - You return early from the method if there is no
photoFiledefined. - The permissions you set before are now revoked since they’re no longer needed.
-
getImageWithPath()is called to get the image from the new photo path, andupdateImage()is called to update the bookmark image.
Build and run the app. Edit a bookmark and tap on the photo. Tap on Camera and then snap a new photo. The bookmark photo updates to show the new photo. Go back to the map view, and then edit the same bookmark again to verify that the new photo is displayed.
Select an existing image
Now you’ll add the option to pick an existing image from the device’s gallery.
When selecting from the device gallery, you don’t provide a temporary file for the image storage. Instead, the image selection activity gives you a Uri to the selected image.
You’ll need a new method that reads an image from a Uri input stream.
Open ImageUtils.kt and add the following method:
fun decodeUriStreamToSize(
uri: Uri,
width: Int,
height: Int,
context: Context
): Bitmap? {
var inputStream: InputStream? = null
try {
val options: BitmapFactory.Options
// 1
inputStream = context.contentResolver.openInputStream(uri)
// 2
if (inputStream != null) {
// 3
options = BitmapFactory.Options()
options.inJustDecodeBounds = false
BitmapFactory.decodeStream(inputStream, null, options)
// 4
inputStream.close()
inputStream = context.contentResolver.openInputStream(uri)
if (inputStream != null) {
// 5
options.inSampleSize = calculateInSampleSize(
options.outWidth, options.outHeight,
width, height)
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeStream(
inputStream, null, options)
inputStream.close()
return bitmap
}
}
return null
} catch (e: Exception) {
return null
} finally {
// 6
inputStream?.close()
}
}
This uses the same technique as decodeFileToSize() to read in the size of the image first, calculate the sample size and then load in the downsampled image. The main difference is that it reads from the Uri stream instead of a file.
-
inputStreamis opened for theUri. - If the
inputStreamis notnull, then processing continues. - The image size is determined.
- The input stream is closed and opened again, and checked for
null. - The image is loaded from the stream using the downsampling options and is returned to the caller.
- You must close the
inputStreamonce it’s opened, even if an exception is thrown.
You’ll need a new request code to identify the results from the image selection activity.
Open BookmarkDetailsActivity.kt and add the following to the companion object:
private const val REQUEST_GALLERY_IMAGE = 2
You can now replace the empty onPickClick() with a version that kicks off Android’s image selection activity.
Replace the contents of onPickClick() with the following:
val pickIntent = Intent(Intent.ACTION_PICK,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
startActivityForResult(pickIntent, REQUEST_GALLERY_IMAGE)
To process the results of the image selection, you need a method that returns a downsampled Bitmap from a Uri path.
Add the following method:
private fun getImageWithAuthority(uri: Uri) = ImageUtils.decodeUriStreamToSize(
uri,
resources.getDimensionPixelSize(R.dimen.default_image_width),
resources.getDimensionPixelSize(R.dimen.default_image_height),
this
)
This method uses the new decodeUriStreamToSize method to load the downsampled image and return it.
Next, you need to add a new case to handle existing images in onActivityResult(). This time you’ll handle the result of the image selection activity.
In onActivityResult(), add the following new clause to the when conditional block:
REQUEST_GALLERY_IMAGE -> if (data != null && data.data != null) {
val imageUri = data.data as Uri
val image = getImageWithAuthority(imageUri)
image?.let {
val bitmap = ImageUtils.rotateImageIfRequired(this, it, imageUri)
updateImage(bitmap)
}
}
If the Activity result is from selecting a gallery image, and the data returned is valid, then getImageWithAuthority() is called to load the selected image. updateImage() is called to update the bookmark image.
A new permission is needed to read the selected image if the user selects it from external storage.
Open AndroidManifest.xml and add the following before the <application section:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
Build and run the app. Edit a bookmark and tap on the photo. Tap on Gallery and then select an existing photo. The bookmark photo updates to show the selected photo.
Go back to the map view and then edit the same bookmark again to verify that the new photo is displayed.
Key Points
- Navigation drawers are useful for information that is not needed all the time.
- Actions can be performed on navigation drawer items.
- Refactoring is easy to do in Android Studio.
- Importing camera and gallery images takes some work but is very useful.
Where to go from here?
Great job! You’ve added some key features to the app and have completed the primary bookmarking features. In the next chapter, you’ll add some finishing touches that will kick the app up a notch.