Chapters

Hide chapters

Android Apprentice

Fourth Edition · Android 11 · Kotlin 1.4 · Android Studio 4.1

Section II: Building a List App

Section 2: 7 chapters
Show chapters Hide chapters

Section III: Creating Map-Based Apps

Section 3: 7 chapters
Show chapters Hide chapters

18. Navigation & Photos
Written by Kevin D Moore

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

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.

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"
  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>
<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>
<?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>
<?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>
<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>

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
databinding = ActivityMapsBinding.inflate(layoutInflater)
setContentView(databinding.root)
private fun setupToolbar() {
  setSupportActionBar(databinding.mainMapView.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, databinding.drawerLayout, databinding.mainMapView.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(
    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
}
private lateinit var bookmarkListAdapter: BookmarkListAdapter
private fun setupNavigationDrawer() {
  val layoutManager = LinearLayoutManager(this)
  databinding.drawerViewMaps.bookmarkRecyclerView.layoutManager = layoutManager
  bookmarkListAdapter = BookmarkListAdapter(null, this)
  databinding.drawerViewMaps.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
  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)
}
init {
  binding.root.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) =
      // 16
      if (canPick(context) || canCapture(context)) {
          PhotoOptionDialogFragment()
      } else {
          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")
}
databinding.imageViewPlace.setOnClickListener {
  replaceImage()
}

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>

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
Ozugavuc Deqoqa Doox

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)
}
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
}
@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
  }
}
fun setImage(context: Context, image: Bitmap) {
  id?.let {
    ImageUtils.saveBitmapToFile(context, image,
        Bookmark.generateImageFilename(it))
  }
}
private fun updateImage(image: Bitmap) {
  bookmarkDetailsView?.let {
    databinding.imageViewPlace.setImageBitmap(image)
    it.setImage(this, image)
  }
}
private fun getImageWithPath(filePath: String) = 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)
        val bitmap = ImageUtils.rotateImageIfRequired(this, image , uri)
        updateImage(bitmap)
      }
    }
  }
}

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) = 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 {
    val bitmap = ImageUtils.rotateImageIfRequired(this, it, imageUri)
    updateImage(bitmap)
  }
}
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

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.

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.
© 2024 Kodeco Inc.

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 Kodeco Personal Plan.

Unlock now