Home Android & Kotlin Books Android Apprentice

18
Navigation and Photos Written by Tom Blankenship

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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/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

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.

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.

<?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"
    tools:context="com.raywenderlich.placebook.ui.MapsActivity"
    android:orientation="vertical">
    
  <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"
      xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:map="http://schemas.android.com/apk/res-auto"
      xmlns:tools="http://schemas.android.com/tools"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      tools:context="com.raywenderlich.placebook.ui.MapsActivity"
      />
</LinearLayout>
<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:orientation="vertical"
    android:background="#ddd">
  <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:scrollbars="vertical"
      android:layout_width="match_parent"
      android:layout_height="match_parent"/>
</LinearLayout>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingTop="10dp"
    android:paddingBottom="10dp"
    android:paddingLeft="16dp"
    android:paddingRight="16dp">
    
  <ImageView
      android:id="@+id/bookmarkIcon"
      android:layout_width="30dp"
      android:layout_height="30dp"
      android:layout_marginEnd="16dp"
      android:adjustViewBounds="true"
      android:scaleType="fitStart"/>
  <TextView
      android:id="@+id/bookmarkNameTextView"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_gravity="center_vertical"
      tools:text="Name"/>
</LinearLayout>
<?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 layout="@layout/main_view_maps"/>
  <include layout="@layout/drawer_view_maps"/>

</androidx.drawerlayout.widget.DrawerLayout>
<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>
private fun setupToolbar() {
  setSupportActionBar(toolbar)
}
setupToolbar()

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.

<string name="open_drawer">Open Drawer</string>
<string name="close_drawer">Close Drawer</string>
val toggle = ActionBarDrawerToggle(
    this,  drawerLayout, toolbar,
    R.string.open_drawer, R.string.close_drawer)    
toggle.syncState()

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.

// 1
class BookmarkListAdapter(
    private var bookmarkData: List<BookmarkView>?,
    private val mapsActivity: MapsActivity) :
    RecyclerView.Adapter<BookmarkListAdapter.ViewHolder>() {
  // 2
  class ViewHolder(v: View,
      private val mapsActivity: MapsActivity) :
      RecyclerView.ViewHolder(v) {
    val nameTextView: TextView = v.bookmarkNameTextView
    val categoryImageView: ImageView = v.bookmarkIcon
  }
  // 3
  fun setBookmarkData(bookmarks: List<BookmarkView>) {
    this.bookmarkData = bookmarks
    notifyDataSetChanged()
  }
  // 4
  override fun onCreateViewHolder(
      parent: ViewGroup,
      viewType: Int): BookmarkListAdapter.ViewHolder {
    val vh = ViewHolder(
        LayoutInflater.from(parent.context).inflate(
        R.layout.bookmark_item, parent, false), mapsActivity)
    return vh
  }

  override fun onBindViewHolder(holder: ViewHolder,
      position: Int) {
    // 5
    val bookmarkData = bookmarkData ?: return
    // 6
    val bookmarkViewData = bookmarkData[position]
    // 7
    holder.itemView.tag = bookmarkViewData
    holder.nameTextView.text = bookmarkViewData.name
    holder.categoryImageView.setImageResource(
        R.drawable.ic_other)
  }

  // 8
  override fun getItemCount(): Int {
    return bookmarkData?.size ?: 0
  }
}
private lateinit var bookmarkListAdapter: BookmarkListAdapter
private fun setupNavigationDrawer() {
  val layoutManager = LinearLayoutManager(this)
  bookmarkRecyclerView.layoutManager = layoutManager
  bookmarkListAdapter = BookmarkListAdapter(null, this)
  bookmarkRecyclerView.adapter = bookmarkListAdapter
}
setupNavigationDrawer()
bookmarkListAdapter.setBookmarkData(it)

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.

private var markers = HashMap<Long, Marker>()
bookmark.id?.let { markers.put(it, marker) }
markers.clear()
private fun updateMapToLocation(location: Location) {
  val latLng = LatLng(location.latitude, location.longitude)
  map.animateCamera(
      CameraUpdateFactory.newLatLngZoom(latLng, 16.0f))
}
fun moveToBookmark(bookmark: MapsViewModel.BookmarkView) {
  // 1
  drawerLayout.closeDrawer(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)
}
init {
  v.setOnClickListener {
    val bookmarkView = itemView.tag as BookmarkView
    mapsActivity.moveToBookmark(bookmarkView)
  }
}
@Query("SELECT * FROM Bookmark ORDER BY name")

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.

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):
        PhotoOptionDialogFragment? {
      // 16
      if (canPick(context) || canCapture(context)) {
        val frag = PhotoOptionDialogFragment()
        return frag
      } else {
        return null
      }
    }
  }
}
class BookmarkDetailsActivity : AppCompatActivity(),
    PhotoOptionDialogFragment.PhotoOptionDialogListener {
override fun onCaptureClick() {
  Toast.makeText(this, "Camera Capture",
      Toast.LENGTH_SHORT).show()
}
override fun onPickClick() {
  Toast.makeText(this, "Gallery Pick",
      Toast.LENGTH_SHORT).show()
}
private fun replaceImage() {
  val newFragment = PhotoOptionDialogFragment.newInstance(this)
  newFragment?.show(supportFragmentManager, "photoOptionDialog")
}
imageViewPlace.setOnClickListener {
  replaceImage()
}

Capturing an image

Capturing a full-size image from Android consists of the following steps:

Generate a unique filename

First, you need to create a helper method to generate a unique image filename.

@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)
}
private var photoFile: File? = null

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.

companion object {
  private const val REQUEST_CAPTURE_IMAGE = 1
}
// 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)
}

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>
<?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>
Emulator Camera View
Ajojemap Vafono Foan

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.

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
}
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)
}
fun setImage(context: Context, image: Bitmap) {
  id?.let {
    ImageUtils.saveBitmapToFile(context, image,
        Bookmark.generateImageFilename(it))
  }
}
private fun updateImage(image: Bitmap) {
  val bookmarkView = bookmarkDetailsView ?: return
  imageViewPlace.setImageBitmap(image)
  bookmarkView.setImage(this, image)
}
private fun getImageWithPath(filePath: String): Bitmap? {
  return ImageUtils.decodeFileToSize(filePath,
      resources.getDimensionPixelSize(
          R.dimen.default_image_width),
      resources.getDimensionPixelSize(
          R.dimen.default_image_height))
}
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)
        image?.let { updateImage(it) }
      }
    }
  }
}

Select an existing image

Now you’ll add the option to pick an existing image from the device’s gallery.

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()
  }
}
private const val REQUEST_GALLERY_IMAGE = 2
val pickIntent = Intent(Intent.ACTION_PICK,
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
startActivityForResult(pickIntent, REQUEST_GALLERY_IMAGE)
private fun getImageWithAuthority(uri: Uri): Bitmap? {
  return ImageUtils.decodeUriStreamToSize(uri,
      resources.getDimensionPixelSize(
          R.dimen.default_image_width),
      resources.getDimensionPixelSize(
          R.dimen.default_image_height),
      this)
}
REQUEST_GALLERY_IMAGE -> if (data != null && data.data != null) {
  val imageUri = data.data as Uri
  val image = getImageWithAuthority(imageUri)
  image?.let { updateImage(it) }
}
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.