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

17. Detail Activity
Written by Kevin D Moore

In this chapter, you’ll add the ability to edit bookmarks. This involves creating a new Activity to display the bookmark details with editable fields.

Getting started

If you’re following along with your own app, copy ic_action_done.png from the starter project in the res/drawable-xxxx/ folders into your project. Make sure to copy the files from all of the drawable folders, including everything with the .hdpi, .mdpi, .xhdpi, and .xxhdpi extensions.

If you want to use the starter project instead, locate the projects folder for this chapter and open the PlaceBook app inside the starter folder. 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.

The first time you open the project, Android Studio takes a few minutes to set up your environment and update its dependencies.

Fixing the info window

Before moving on, you need to track down and fix that pesky bug left over from the previous chapter where the app crashes when tapping on a blue marker. The desired behavior is:

  • If the user taps a new place, it shows a red marker and the Info window. If the user then taps on the Info window, you save a bookmark to the database and the marker turns blue.

  • If the user taps on a blue marker, it displays the saved bookmark info, including the image.

Build and run the app again, and tap on an existing bookmark icon. After the app crashes, look at Logcat. Find the most recent stack trace line that has your app’s package name, and you’ll find the following line:

com.raywenderlich.placebook.adapter.BookmarkInfoWindowAdapter.getInfoContents

This error is a ClassCastException error informing you that a BookmarkMarkerView cannot be cast to a MapActivityPlaceInfo class.

To find out what’s going on, click the blue link for BookmarkInfoWindowAdapter.kt, which takes you to the offending line of code:

imageView.setImageBitmap(
    (marker.tag as MapsActivity.PlaceInfo).image)

The problem is that this code assumes that marker.tag contains an object of type MapsActivity.PlaceInfo. However, that’s not always the case because a marker can now represent two types of places: one is a temporary place that isn’t bookmarked yet, and the other is a place that has an existing bookmark.

To fix this, you need to update the code to take a different action based on the marker tag type.

In BookmarkInfoWindowAdapter.kt replace the line in getInfoContents() that calls setImageBitmap() with the following:

when (marker.tag) {
  // 1
  is MapsActivity.PlaceInfo -> {
    imageView.setImageBitmap(
        (marker.tag as MapsActivity.PlaceInfo).image)
  }
  // 2  
  is MapsViewModel.BookmarkMarkerView -> {
    val bookMarkview = marker.tag as
        MapsViewModel.BookmarkMarkerView
    // Set imageView bitmap here
  }
}

Then add the MapsViewModel import.

The when statement is used to run conditional code based on the class type of marker.tag.

  1. If marker.tag is a MapsActivity.PlaceInfo, you set the imageView bitmap directly from the PlaceInfo.image object.

  2. If marker.tag is a MapsViewModel.BookmarkMarkerView, you set the imageView bitmap from the BookmarkMarkerView.

The only problem is that BookmarkMarkerView doesn’t contain a bookmark image because you haven’t saved images with the bookmarks yet.

Saving an image

Although you can add an image directly to the Bookmark model class and let the Room library save it to the database, it’s not a best practice to store large chunks of data in the database. A better method is to store the image as a file that’s linked to the record in the database.

Android doesn’t provide a simple way to save images to a file, so you first need to create a new image utility class, and add a method to save an image to a file.

In the Project navigator, click java/com.raywenderlich.placebook, select File ▸ New ▸ Package and create a new package named util. Inside util, create a new Kotlin class named ImageUtils.kt.

Replace the contents of ImageUtils.kt with the following:

// 1
object ImageUtils {
  // 2
  fun saveBitmapToFile(context: Context, bitmap: Bitmap,
      filename: String) {      
    // 3
    val stream = ByteArrayOutputStream()
    // 4
    bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
    // 5
    val bytes = stream.toByteArray()
    // 6
    saveBytesToFile(context, bytes, filename)
  }
  // 7
  private fun saveBytesToFile(context: Context, bytes:
      ByteArray, filename: String) {
    val outputStream: FileOutputStream
    // 8
    try {    
      // 9
      outputStream = context.openFileOutput(filename,
          Context.MODE_PRIVATE)
      // 10
      outputStream.write(bytes)
      outputStream.close()
    } catch (e: Exception) {
      e.printStackTrace()
    }
  }
}

Here’s the code breakdown:

  1. ImageUtils is declared as an object, so it behaves like a singleton. This lets you directly call the methods within ImageUtils without creating a new ImageUtils object each time.
  2. saveBitmapToFile() takes in a Context, Bitmap and String for the filename, and saves the Bitmap to permanent storage.
  3. ByteArrayOutputStream is created to hold the image data.
  4. You write the image bitmap to the stream object using the lossless PNG format. Note that the second parameter is a quality setting, but it’s ignored for the PNG format.
  5. The stream is converted into an array of bytes.
  6. saveBytesToFile() is called to write the bytes to a file.
  7. saveBytesToFile() takes in a Context, ByteArray, and a String for the filename and saves the bytes to a file.
  8. The next few calls may throw exceptions, so they’re wrapped in a try/catch to prevent a crash.
  9. openFileOutput is used to open a FileOutputStream using the given filename. The Context.MODE_PRIVATE flag causes the file to be written in the private area where only the PlaceBook app can access it.
  10. The bytes are written to the outputStream and then the stream is closed.

Now that you have saveBitmapToFile() set up, you can give the Bookmark object the ability to save a bitmap image for itself. This method will automatically generate a filename for the bitmap that matches the bookmark ID.

Open Bookmark.kt inside model and add the following code to the bottom of the file:

{
  // 1
  fun setImage(image: Bitmap, context: Context) {
    // 2
    id?.let {
      ImageUtils.saveBitmapToFile(context, image,
          generateImageFilename(it))
    }
  }
  //3
  companion object {
    fun generateImageFilename(id: Long): String {
      // 4
      return "bookmark$id.png"
    }
  }
}

Bookmark was previously a simple data class with only default functions provided by Kotlin. This adds a body to the class.

  1. setImage() provides the public interface for saving an image for a Bookmark.

  2. If the bookmark has an id, then the image gets saved to a file. The filename incorporates the bookmark ID to make sure it’s unique.

  3. generateImageFilename() is placed in a companion object so it’s available at the class level. This allows another object to load an image without having to load the bookmark from the database.

  4. generateImageFilename() returns a filename based on a Bookmark ID. It uses a simple algorithm that appends the bookmark ID to the word “bookmark”. For example, for bookmark ID 5, the associated image is named bookmark5.png. Since you can always infer the bookmark image filename from the bookmark ID, there’s no need to save the filename as a separate field in the database.

Adding the image to the bookmark

Next, you need to set the image for a bookmark when it’s added to the database.

Open MapsViewModel.kt and add the following line in addBookmarkFromPlace() after the call to set bookmarkRepo.addBookmark().

image?.let { bookmark.setImage(it, getApplication()) }

Here, you update addBookmarkFromPlace() to call the new setImage() method if the image is not null.

It’s important to call this after the bookmark is saved to the database so that the bookmark has a unique ID assigned.

setImage() is used to save the image to the bookmark, and the application context is passed into setImage() using getApplication().

Simplifying the bookmark process

Before testing this new functionality there’s a small change you can make to simplify the process of adding a new bookmark.

Currently, when selecting a place, a marker is displayed, and then the user has to tap on the marker again to display the info box. This change automatically displays the info box when showing the marker.

Open MapsActivity.kt and add the following line to the end of displayPoiDisplayStep():

marker?.showInfoWindow()

This instructs the map to display the Info window for the marker.

Build and run the app. Tap on a new place to display a marker and the Info window. Then, tap on the Info window, which triggers a new bookmark for saving — and this time it stores the bitmap image to a file.

Using Device File Explorer

If you want to verify the image was saved and take a peek behind the scenes at how Android stores files, you can use the Device File Explorer in Android Studio. This is a handy tool for working directly with the Android file system.

Click the Device File Explorer tool on the right side of the Android Studio window. If you don’t see it, click View ▸ Tool Windows ▸ Device File Explorer.

In the newly displayed window, select the device on which you’re running PlaceBook, and then navigate to data/data/com.raywenderlich.placebook/files. If the image save worked correctly, you’ll see at least one bookmark{ID}.png image in the directory. Double-click on the image to preview it.

Loading an image

It’s time to load the image from a file. This is considerably easier than saving an image because Android provides a method on BitmapFactory for loading images from files.

In ImageUtils.kt add the following method:

fun loadBitmapFromFile(context: Context, filename: String): Bitmap? {
  val filePath = File(context.filesDir, filename).absolutePath
  return BitmapFactory.decodeFile(filePath)
}

This method is passed a context and a filename and returns a Bitmap image by loading the image from the specified filename. A File object is used to combine the files directory for the given context with the filename. A filePath is constructed from the absolute path of the File. The BitmapFactory.decodeFile() does the work of loading the image from the file, and the image is returned to the caller.

Updating BookmarkMarkerView

Now that you can load the image from where it’s stored, it’s time to update BookmarkMarkerView to provide the image for the View.

Your first instinct might be to add a new Bitmap object to BookmarkMarkerView and store it alongside the other properties. While this might work fine for a small set of bookmarks, you’ll start eating up a lot of memory if a user has bookmarked hundreds of places. A better solution is to load the images on-demand.

Loading images on-demand

Open MapsViewModel.kt and replace the BookmarkMarkerView data class definition with the following:

data class BookmarkMarkerView(
    var id: Long? = null,
    var location: LatLng = LatLng(0.0, 0.0)
) {
  fun getImage(context: Context) = id?.let {
      ImageUtils.loadBitmapFromFile(context, Bookmark.generateImageFilename(it))
  }
}

Previously, BookmarkMarkerView was a simple data class with only default functions provided by Kotlin. This adds a body to the class with the new getImage function.

In getImage(), you first check to make sure the BookmarkMarkerView has a valid ID. Then, you call generateImageFilename() and pass in the bookmark ID represented as id. You call loadBitmapFromFile() with the current context and Bookmark image filename, and it returns the resulting Bitmap to the caller.

You need to update the Info window Adapter to load the image when it’s done rendering. First, you need a Context object to load the image. You can take advantage of the fact that the BookmarkInfoWindowAdapter constructor already has a context passed in.

Open BookmarkInfoWindowAdapter.kt and change the constructor to the following:

class BookmarkInfoWindowAdapter(val context: Activity) :
    GoogleMap.InfoWindowAdapter {

The only difference is the addition of the val modifier. This makes context a property so you can use it later to load the image.

Add the following code in getInfoContents() after the comment // Set imageView bitmap here:

imageView.setImageBitmap(bookMarkview.getImage(context))

Build and run the app. Tap on a blue marker for a bookmark that was saved after you added the ability to save images.

This displays the Info window with the bookmark image.

The image is showing, but there’s no bookmark information along with it. You can fix this by adding the bookmark name and phone number to BookmarkMarkerView.

Updating the Info window

Open MapsViewModel.kt and update the BookmarkMarkerView declaration to match the following:

data class BookmarkMarkerView(
    var id: Long? = null,
    var location: LatLng = LatLng(0.0, 0.0),
    var name: String = "",
    var phone: String = ""
) {

Note: Make sure to only replace the declaration of BookmarkMarkerView, not the whole class.

This adds new properties for name and phone to BookmarkMarkerView.

Update bookmarkToMarkerView() to match the following:

private fun bookmarkToMarkerView(bookmark: Bookmark) = BookmarkMarkerView(
      bookmark.id,
      LatLng(bookmark.latitude, bookmark.longitude),
      bookmark.name,
      bookmark.phone
)

The only change is that the bookmark name and phone properties are passed into the new BookmarkMarkerView constructor.

Open MapsActivity.kt. In addPlaceMarker(), update the call to map.addMarker() with the following:

val marker = map.addMarker(MarkerOptions()
    .position(bookmark.location)
    .title(bookmark.name)
    .snippet(bookmark.phone)
    .icon(BitmapDescriptorFactory.defaultMarker(
        BitmapDescriptorFactory.HUE_AZURE))
    .alpha(0.8f))

The only change here is that the title and snippet items are set to the bookmark name and phone.

Build and run the app. Tap on a blue marker for a saved bookmark. This time, notice it displays the name and phone number beside the image.

If you tap on the Info window or on another marker, the app will crash. You’ll fix that soon!

Bookmark detail activity

You’ve waited patiently, and it’s finally time to build out the detail Activity for editing a bookmark. For that, you’ll add a new screen that allows the user to edit key details about the bookmark, along with a custom note. You’ll do this by creating a new Activity that displays when a user taps on an Info window.

Designing the edit screen

Before creating the Activity, let’s go over the screen layout and the main elements that will be incorporated.

The Bookmark Edit Layout
The Bookmark Edit Layout

  • The top of the view contains a CoordinatorLayout and an AppBarLayout inside of that.
  • Within the AppBarLayout is a Toolbar.
  • Below the AppBarLayout is a NestedScrollView with a ConstraintLayout to hold the main list of bookmark items.
  • The first item after the toolbar is the image view.
  • Below the image view is a series of AppCompatTextView’s for the labels and TextInputEditText’s for the edit fields.

Introducing Data Binding

Previously Google recommended using Kotlin Android extensions for referring to layout fields in classes. Now Google recommends using either View Binding or Data Binding. From Google:

View binding is a feature that allows you to more easily write code that interacts with views. Once view binding is enabled in a module, it generates a binding class for each XML layout file present in that module. An instance of a binding class contains direct references to all views that have an ID in the corresponding layout.

The Data Binding Library is a support library that allows you to bind UI components in your layouts to data sources in your app using a declarative format rather than programmatically.

Data Binding can be used just by adding dataBinding true in the buildFeatures gradle setting. Pretty similar to the way View Binding is enabled. This will generate a binding class that you can retrieve your layout fields.

View Binding has faster compilation than Data Binding, but can not use layout variables or expressions.

Data Binding works by wrapping layouts in the <layout> tag. You can add variables to your layout that can then be used in the fields themselves. When you add this tag, the data binding generator in the Android gradle plugin generates a Java binding file. If you create a layout named activity_bookmark_details.xml, it will create a file named ActivityBookmarkDetailsBindingImpl.java. This class has methods for setting variables and binding the data to the fields. You will see an example shortly.

In the app build.gradle file, add the following line at the end of the buildFeatures section:

buildFeatures {
  viewBinding true
  dataBinding true  // add this line
}

Click “Sync Now” at the top of the build.gradle file.

Defining styles

First, you need to define some standard styles that are required when using the support library version of the toolbar.

Add the following to res/values/styles.xml:

<style name="AppTheme.NoActionBar">
  <item name="windowActionBar">false</item>
  <item name="windowNoTitle">true</item>
</style>

<style name="AppTheme.AppBarOverlay" 
       parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
<style name="AppTheme.PopupOverlay" 
       parent="ThemeOverlay.AppCompat.Light" />

The NoActionBar style is used to hide the native ActionBar. AppBarOverlay gives the Toolbar Layout a dark theme, and PopupOverlay gives the Toolbar content a light theme.

The bookmark details Activity will contain a list of text labels and fields that all have the same style.

You’ll capitalize on this by defining some styles that you can apply to the labels and fields without repeating information in the Activity Layout definition. This will also make it easier in the future to update the styles of all labels and text fields with a single change.

Add the following to res/values/styles.xml:

<style name="BookmarkLabel">
  <item name="android:layout_width">0dp</item>
  <item name="android:layout_height">wrap_content</item>
  <item name="android:layout_weight">0.2</item>
  <item name="android:layout_gravity">bottom</item>
  <item name="android:layout_marginStart">8dp</item>
  <item name="android:layout_marginLeft">8dp</item>
  <item name="android:layout_marginBottom">4dp</item>
  <item name="android:gravity">bottom</item>
</style>

<style name="BookmarkEditText">
  <item name="android:layout_width">0dp</item>
  <item name="android:layout_weight">0.8</item>
  <item name="android:layout_height">wrap_content</item>
  <item name="android:layout_marginEnd">8dp</item>
  <item name="android:layout_marginRight">8dp</item>
  <item name="android:layout_marginStart">8dp</item>
  <item name="android:layout_marginLeft">8dp</item>
  <item name="android:ems">10</item>
</style>

The BookmarkLabel style defines the attributes for all bookmark labels. BookmarkEditText defines the attributes for all bookmark edit fields.

Creating the details layout

Finally, you need to create the bookmark details Layout based on the design. The Activity will use all of the new styles you just added to the project.

In the app build.gradle file, add the following line in the dependencies section:

implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation 'com.google.android.material:material:1.2.1'

Then click “Sync Now” at the top of the build.gradle file. This adds the material design and constraint layout libraries to the app.

Open up strings.xml in the res ▸ values folder and add the following strings:

<string name="name">Name</string>
<string name="address">Address</string>
<string name="phone">Phone</string>
<string name="phone_number">Phone number</string>
<string name="notes">Notes</string>
<string name="enter_notes">Enter notes</string>

This will provide strings for the layout.

Create a new Layout resource file at res/layout/activity_bookmark_details.xml, and replace its contents with the following:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto">
  <androidx.coordinatorlayout.widget.CoordinatorLayout    
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <com.google.android.material.appbar.AppBarLayout
      android:id="@+id/appbar"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:theme="@style/AppTheme.AppBarOverlay">
        <com.google.android.material.appbar.MaterialToolbar
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          app:contentScrim="?attr/colorPrimary"
          app:layout_scrollFlags="scroll|exitUntilCollapsed"
          app:toolbarId="@+id/toolbar">
          <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            app:popupTheme="@style/AppTheme.PopupOverlay" />
        </com.google.android.material.appbar.MaterialToolbar>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.core.widget.NestedScrollView
      android:layout_width="match_parent"
      android:layout_height="match_parent"                                           app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">

      <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <androidx.appcompat.widget.AppCompatImageView
          android:id="@+id/imageViewPlace"
          android:layout_width="0dp"
          android:layout_height="wrap_content"
          android:adjustViewBounds="true"
          android:maxHeight="300dp"
          android:scaleType="fitCenter"
          app:layout_constraintEnd_toEndOf="parent"
          app:layout_constraintStart_toStartOf="parent"
          app:layout_constraintTop_toTopOf="parent"
          app:srcCompat="@drawable/default_photo" />
      </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.core.widget.NestedScrollView>
  </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

This defines the basic Layout for the bookmark details screen. The layout starts with the <layout> tag, as required by the Data Binding library. The whole layout is contained within a CoordinatorLayout, which is used for handling scrolling and toolbars.

The AppBarLayout is defined as the first item in the Layout, and the styles you defined earlier are used to theme the Toolbar. The bookmark image is placed below the Toolbar. Below the AppBarLayout, we use a NestedScrollView so that if the number of fields does not fit on the screen, the user can scroll the content. Inside of that is our ConstraintLayout, which is Android’s newest layout and makes it easy to position elements. The ImageView is the first item in the layout.

The Layout up to this point looks like this:

Next, you need to add two columns: one for the labels and one for the edit fields that represent the editable bookmark details. Each of these columns will be represented with AppCompatTextViews on the left and an TextInputEditText elements on the right.

First, add a column for the labels by adding the following code below the <AppCompatImageView> element with id imageViewPlace:

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/textViewName"
  style="@style/BookmarkLabel"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/name"
  android:layout_marginTop="16dp"
  app:layout_constraintBaseline_toBaselineOf="@+id/editTextName"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/imageViewPlace" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/textViewNotes"
  style="@style/BookmarkLabel"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:inputType="textMultiLine"
  android:text="@string/notes"
  app:layout_constraintBaseline_toBaselineOf="@+id/editTextNotes"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/textViewName" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/textViewPhone"
  style="@style/BookmarkLabel"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/phone"
  app:layout_constraintBaseline_toBaselineOf="@+id/editTextPhone"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/textViewNotes" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/textViewAddress"
  style="@style/BookmarkLabel"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/address"
  app:layout_constraintBaseline_toBaselineOf="@+id/editTextAddress"
  app:layout_constraintEnd_toStartOf="@+id/barrier1"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/textViewPhone" />

<androidx.constraintlayout.widget.Barrier
  android:id="@+id/barrier1"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  app:barrierDirection="start"
app:constraint_referenced_ids="editTextName, editTextNotes,editTextPhone, editTextAddress" />

Here we have labels for: Name, Notes, Phone, and Address. You will see some errors as we haven’t added the edit fields yet. The barrier is used to align the fields.

You’re using the BookmarkLabel style defined earlier to style your labels.

Next, add a column for the edit fields by adding the following code after the previous barrier:

<com.google.android.material.textfield.TextInputEditText
  android:id="@+id/editTextName"
  style="@style/BookmarkEditText"
  android:layout_width="0dp"
  android:layout_height="wrap_content"
  android:hint="@string/name"
  android:layout_marginTop="16dp"
  android:layout_marginStart="16dp"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/barrier1"
  app:layout_constraintTop_toBottomOf="@+id/imageViewPlace" />

<com.google.android.material.textfield.TextInputEditText
  android:id="@+id/editTextNotes"
  style="@style/BookmarkEditText"
  android:layout_width="0dp"
  android:layout_height="wrap_content"
  android:hint="@string/enter_notes"
  android:layout_marginStart="16dp"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/barrier1"
  app:layout_constraintTop_toBottomOf="@+id/editTextName" />

<com.google.android.material.textfield.TextInputEditText
  android:id="@+id/editTextPhone"
  style="@style/BookmarkEditText"
  android:layout_width="0dp"
  android:layout_height="wrap_content"
  android:hint="@string/phone_number"
  android:layout_marginStart="16dp"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/barrier1"
  app:layout_constraintTop_toBottomOf="@+id/editTextNotes" />

<com.google.android.material.textfield.TextInputEditText
  android:id="@+id/editTextAddress"
  style="@style/BookmarkEditText"
  android:layout_width="0dp"
  android:layout_height="wrap_content"
  android:hint="@string/address"
  android:inputType="textMultiLine"
  android:layout_marginStart="16dp"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/barrier1"
  app:layout_constraintTop_toBottomOf="@+id/editTextPhone" />

You’re using the BookmarkEditText style defined earlier to style your edit fields. The final Layout will look like this:

Details activity class

Now that the bookmark details Layout is complete, you can create the details Activity to go along with it.

Inside ui, create a new Activity named BookmarkDetailsActivity.kt by choosing New ▸ Activity ▸ Empty Activity:

Give the name BookmarkDetailsActivity and uncheck “Generate a Layout File”:

Select Finish and replace the contents with the following:

class BookmarkDetailsActivity : AppCompatActivity() {
  private lateinit var databinding: ActivityBookmarkDetailsBinding
  
  override fun onCreate(savedInstanceState: android.os.Bundle?) {
    super.onCreate(savedInstanceState)
    databinding = DataBindingUtil.setContentView(this, R.layout.activity_bookmark_details)
    setupToolbar()
  }

  private fun setupToolbar() {
    setSupportActionBar(databinding.toolbar)
  }
}

This is a fairly standard Activity class that sets the content view with the DataBindingUtil helper class that will create our binding class and uses the support action bar. setupToolbar() calls the built-in setSupportActionBar() to make the Toolbar act as the ActionBar for this Activity. By using the support library version of the Toolbar, you ensure that it works the same across a variety of devices.

Updating the manifest

Next, open AndroidManifest.xml and replace the BookmarkDetailsActivity activity with:

<activity
    android:name=
        "com.raywenderlich.placebook.ui.BookmarkDetailsActivity"
    android:label="Bookmark"
    android:theme="@style/AppTheme.NoActionBar"
    android:windowSoftInputMode="stateHidden">
</activity>

Note that the NoActionBar theme is required when using the support Toolbar. android:windowSoftInputMode is set to stateHidden to prevent the soft keyboard from displaying when the activity is first displayed.

Starting the details Activity

You can now hook up the new details Activity to the main maps Activity. You’ll detect when the user taps on a bookmark Info window, and then start the details Activity.

Add the following method to MapsActivity.kt:

private fun startBookmarkDetails(bookmarkId: Long) {
  val intent = Intent(this, BookmarkDetailsActivity::class.java)
  startActivity(intent)
}

Here, startBookmarkDetails() is used to start the BookmarkDetailsActivity using an explicit Intent. You’ll call this method when the user taps on an Info window for an existing bookmark.

Replace handleInfoWindowClick() with the following:

private fun handleInfoWindowClick(marker: Marker) {
  when (marker.tag) {
    is PlaceInfo -> {
      val placeInfo = (marker.tag as PlaceInfo)
      if (placeInfo.place != null && placeInfo.image != null) {
        GlobalScope.launch {
          mapsViewModel.addBookmarkFromPlace(placeInfo.place,
              placeInfo.image)
        }
      }
      marker.remove();
    }
    is MapsViewModel.BookmarkMarkerView -> {
      val bookmarkMarkerView = (marker.tag as
          MapsViewModel.BookmarkMarkerView)
      marker.hideInfoWindow()
      bookmarkMarkerView.id?.let {
        startBookmarkDetails(it)
      }
    }
  }
}

This method handles the action when a user taps a place Info window. Previously, it was designed to save the bookmark to the database. Now, it saves the bookmark if it hasn’t been saved before, or it starts the bookmark details Activity if the bookmark has already been saved.

Previously, this method assumed that the marker.tag would always be a PlaceInfo object. Now you’re using the when construct to take a different action based on the marker.tag type. If it’s a BookmarkMarkerView, then the Info window is hidden and you start the bookmark details Activity.

Build and run the app. Tap on a blue bookmark marker, and then tap on the Info window. The new bookmark details screen is shown.

This is a good chance to verify the Layout is working before populating the dialog with the actual bookmark content.

Populating the bookmark

The Activity has the general look you want, but it lacks any knowledge about the bookmark. To fix this, you’ll pass the bookmark ID to the Activity so that it can display the bookmark data.

Open MapsActivity.kt and add the following to the top of the companion object:

const val EXTRA_BOOKMARK_ID =
    "com.raywenderlich.placebook.EXTRA_BOOKMARK_ID"

This defines a key for storing the bookmark ID in the intent extras.

In startBookmarkDetails(), add the following line before the call to startActivity():

intent.putExtra(EXTRA_BOOKMARK_ID, bookmarkId)

This adds the bookmarkId as an extra parameter on the Intent.

Next, you need to retrieve this parameter in the bookmark details Activity and use it to load the bookmark details.

Open BookmarkRepo.kt and add the following method:

fun getLiveBookmark(bookmarkId: Long): LiveData<Bookmark> = 
  bookmarkDao.loadLiveBookmark(bookmarkId)

This method returns a live bookmark from the bookmark DAO.

Just like MapsActivity, BookmarkDetailsActivity uses a ViewModel to coordinate the data between the View and the Model.

You need to create a new View Model class for the details Activity. This class will use the bookmark repo to retrieve the bookmark details and format them for the details Activity.

In the viewmodel directory, create a new Kotlin file named BookmarkDetailsViewModel.kt and replace the contents with the following:

class BookmarkDetailsViewModel(application: Application) : AndroidViewModel(application) {
  private val bookmarkRepo = BookmarkRepo(getApplication())
}

BookmarkDetailsViewModel inherits from AndroidViewModel just like the MapsViewModel class. A private BookmarkRepo property is defined and initialized with a new BookmarkRepo instance.

You’ll follow a similar pattern as you did with MapsViewModel to return data for the View. You can repeat this pattern anytime you need to return live data for a View. It can be generalized as follows:

  1. Define a new data class to hold the info required by the View class.
  2. Define a LiveData property with the new data class.
  3. Define a method to transform LiveData model data to LiveData view data.
  4. Define a method to return the view data to the View.

Add the following internal class to BookmarkDetailsViewModel at the end of the class:

data class BookmarkDetailsView(
    var id: Long? = null,
    var name: String = "",
    var phone: String = "",
    var address: String = "",
    var notes: String = ""
) {

  fun getImage(context: Context) = id?.let {
      ImageUtils.loadBitmapFromFile(context, Bookmark.generateImageFilename(it))
  }
}

BookmarkDetailsView defines the data needed by BookmarkDetailsActivity. getImage() loads the image associated with the bookmark.

Adding the data tag

Now that you have created the BookmarkDetailsView class, you can add a variable to the activity_bookmark_details.xml file. Right after the <layout> tag add:

<data>
  <variable
    name="bookmarkDetailsView"
    type="com.raywenderlich.placebook.viewmodel.BookmarkDetailsViewModel.BookmarkDetailsView" />
</data>

This creates a variable named bookmarkDetailsView with the given type. This variable can be used to populate your TextViews and EditViews.

Find the editTextName TextInputEditText and add:

android:text="@{bookmarkDetailsView.name}"

Then find editTextNotes and add:

android:text="@{bookmarkDetailsView.notes}"

Repeat the same for editTextPhone. Find it in the layout and add:

android:text="@{bookmarkDetailsView.phone}"

Finally, find editTextAddress and add:

android:text="@{bookmarkDetailsView.address}"

Thanks to these changes the Data Binding library will be able to set the text to the given name, notes, phone, and address of the bookmark.

Adding notes to the database

Before continuing, you need a way to store notes for a bookmark.

Open Bookmark.kt and update the Bookmark declaration to add in the notes property:

var notes: String = ""

To look like:

data class Bookmark(
    @PrimaryKey(autoGenerate = true) var id: Long? = null,
    var placeId: String? = null,
    var name: String = "",
    var address: String = "",
    var latitude: Double = 0.0,
    var longitude: Double = 0.0,
    var phone: String = "",
    var notes: String = ""
)

Now that you’ve changed the Bookmark class, the main database class needs to be made aware of it.

Open PlaceBookDatabase.kt and update the @Database annotation version to 2 as follows:

@Database(entities = arrayOf(Bookmark::class), version = 2)

The change to Bookmark requires a change to the underlying database structure managed by Room. Setting the version to 2 lets Room know that something is different about the database.

The first time the app is launched after updating the version, Room tries to migrate data from the old structure to the new structure. It does so by looking for Migrations that you have added to the database builder. If you haven’t added any Migrations, then an exception is thrown and the app crashes.

Rather than providing Migrations, you can prevent the crash by telling Room to create the new database from scratch and discard all old data.

In the companion object’s getInstance(), replace the call to Room.databaseBuilder with the following:

instance = Room.databaseBuilder(context.applicationContext,
    PlaceBookDatabase::class.java, "PlaceBook")
    .fallbackToDestructiveMigration()
    .build()

This adds the fallbackToDestructiveMigration() call the builder and tells Room to create a new empty database if it can’t find any Migrations.

Note: If you want to learn how to handle database schema changes using Migrations, please see the official documentation at https://developer.android.com/topic/libraries/architecture/room.html#db-migration.

Bookmark view model

That’s all you need to support the revised Bookmark model in the database. Now you need to convert the database model to a view model.

Go back to BookmarkDetailsViewModel.kt and add the following method to the BookmarkDetailsViewModel class:

private fun bookmarkToBookmarkView(bookmark: Bookmark): BookmarkDetailsView {
  return BookmarkDetailsView(
      bookmark.id,
      bookmark.name,
      bookmark.phone,
      bookmark.address,
      bookmark.notes
  )
}

This method converts a Bookmark model to a BookmarkDetailsView model. Now, you need a property to hold the current bookmark view object.

Add the following to the top of the class:

private var bookmarkDetailsView: LiveData<BookmarkDetailsView>? = null

The bookmarkDetailsView property holds the LiveData<BookmarkDetailsView> object. This allows the View to stay updated anytime the view model changes.

You have defined a method to convert from the database bookmark to the View bookmark, now you need to convert from a live database bookmark object to a live bookmark view object.

Add the following method:

private fun mapBookmarkToBookmarkView(bookmarkId: Long) {
  val bookmark = bookmarkRepo.getLiveBookmark(bookmarkId)
  bookmarkDetailsView = Transformations.map(bookmark) { repoBookmark ->
    bookmarkToBookmarkView(repoBookmark)
  }
}

Here, you get the live Bookmark from the BookmarkRepo and then transform it to the live BookmarkDetailsView. See the previous chapter for details about how Transformations.map() works.

Finally, you can bring it all together by exposing a method to return a live bookmark View based on a bookmark ID. Add the following method:

fun getBookmark(bookmarkId: Long): LiveData<BookmarkDetailsView>? {
  if (bookmarkDetailsView == null) {
    mapBookmarkToBookmarkView(bookmarkId)
  }
  return bookmarkDetailsView
}

getBookmark() returns the BookmarkDetailsView object. If this is the first time getBookmark() is called, mapBookmarkToBookmarkView() is used to create the bookmarkDetailsView, otherwise the previously created bookmarkDetailsView is returned.

Retrieving the bookmark view

You’re ready to add the code to retrieve the BookmarkDetailsView LiveData object in the View Activity.

First, you need some properties to hold the view model data.

Open BookmarkDetailsActivity.kt and add the following properties:

private val bookmarkDetailsViewModel by 
    viewModels<BookmarkDetailsViewModel>()
private var bookmarkDetailsView:
    BookmarkDetailsViewModel.BookmarkDetailsView? = null

by viewModels<BookmarkDetailsViewModel>() creates the bookmarkDetailsViewModel using the viewModels delegate. If Android Studio has a hard time finding viewModels add this import:

import androidx.activity.viewModels

This is the standard procedure for initializing a view model that you have seen in earlier chapters.

Add the following method:

private fun populateImageView() {
  bookmarkDetailsView?.let { bookmarkView ->
    val placeImage = bookmarkView.getImage(this)
    placeImage?.let {
      databinding.imageViewPlace.setImageBitmap(placeImage)
    }
  }
}

This method loads the image from bookmarkView and then uses it to set the imageViewPlace.

Using the intent data

When the user taps on the Info window for a bookmark on the maps Activity, it passes the bookmark ID to the details Activity.

You now need to add a method in BookmarkDetailsActivity to read this Intent data and use it to populate the UI.

Add the following method:

private fun getIntentData() {
  // 1
  val bookmarkId = intent.getLongExtra(
      MapsActivity.Companion.EXTRA_BOOKMARK_ID, 0)  
  // 2    
  bookmarkDetailsViewModel.getBookmark(bookmarkId)?.observe(this, {
    // 3
    it?.let {
      bookmarkDetailsView = it
      // 4
      databinding.bookmarkDetailsView = it
      populateImageView()
    }
  })
}

Note: If Android Studio gives you multiple choices of imports for the Observer class, make sure to choose import androidx.lifecycle.Observer.

This method is called when the Activity is created. Here’s how it works:

  1. You pull the bookmarkId from the Intent data.
  2. You retrieve the BookmarkDetailsView from BookmarkDetailsViewModel and then observe it for changes.
  3. Whenever the BookmarkDetailsView is loaded or changed, you assign the bookmarkDetailsView property to it and populate the bookmark fields from the data. You call the previously defined functions to populate the fields.
  4. Set the data binding’s bookmarkDetailsView variable and fill in the text fields for you.

Finishing the detail activity

You’re ready to pull everything together by adding the following call to the end of onCreate() in BookmarkDetailsActivity.

getIntentData()

When the bookmark details Activity starts, it processes the Intent data passed in from the maps Activity.

Build and run the app. The previous data is cleared out because of the database schema change.

Add a new bookmark and view the details. The bookmark info is now displayed.

Saving changes

The only major feature left is to save the user’s edits. For that, you’ll add a checkmark Toolbar item to trigger the save.

First, you need a menu resource file to define a checkmark.

Create a new menu resource folder using File ▸ New ▸ Android resource directory with a name of menu and a resource type of menu.

Create a new menu resource file named menu_bookmark_details.xml in res/menu and replace the contents with the following:

<?xml version="1.0" encoding="utf-8"?>
<menu
    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"
    tools:context=
    "com.raywenderlich.placebook.ui.BookmarkDetailsActivity">

  <item
      android:id="@+id/action_save"
      android:icon="@drawable/ic_action_done"
      android:title="Save"
      app:showAsAction="ifRoom"/>
</menu>

This defines a single menu item with an ID of action_save for the detail Activity Toolbar. Now, you need to inflate the menu resource in the details Activity.

Open BookmarkDetailsActivity.kt and add the following method:

override fun onCreateOptionsMenu(menu: android.view.Menu): Boolean {
  menuInflater.inflate(R.menu.menu_bookmark_details, menu)
  return true
}

You override onCreateOptionsMenu and provide items for the Toolbar by loading in the menu_bookmark_details menu.

Note: You may need to terminate any running versions of PlaceBook before Android Studio recognizes the new menu resource.

To save an updated bookmark to the database, you need some new methods in BookmarkRepo. Open BookmarkRepo.kt and add the following methods:

fun updateBookmark(bookmark: Bookmark) {
  bookmarkDao.updateBookmark(bookmark)
}

fun getBookmark(bookmarkId: Long): Bookmark {
  return bookmarkDao.loadBookmark(bookmarkId)
}

updateBookmark() takes in a bookmark and saves it using the bookmark DAO. getBookmark() takes in a bookmark ID and uses the bookmark DAO to load the corresponding bookmark.

When the user makes changes to a bookmark, you need to update the bookmark view model class. For that, you need a method to convert a bookmark view model to the database bookmark model.

Open BookmarkDetailsViewModel.kt and add the following method:

private fun bookmarkViewToBookmark(bookmarkView: BookmarkDetailsView): Bookmark? {
  val bookmark = bookmarkView.id?.let {
    bookmarkRepo.getBookmark(it)
  }
  if (bookmark != null) {
    bookmark.id = bookmarkView.id
    bookmark.name = bookmarkView.name
    bookmark.phone = bookmarkView.phone
    bookmark.address = bookmarkView.address
    bookmark.notes = bookmarkView.notes
  }
  return bookmark
}

This method takes a BookmarkDetailsView and returns a Bookmark with the updated parameters from the BookmarkDetailsView. You load the original bookmark values from the BookmarkRepo before updating them with the BookmarkDetailsView.

It’s important to load in the original bookmark to retain the values that aren’t updated by the BookmarkDetailsView.

You can now utilize bookmarkViewToBookmark() to create a new public method to update a bookmark in the background.

Add the following method:

fun updateBookmark(bookmarkView: BookmarkDetailsView) {
  // 1
  GlobalScope.launch {
    // 2
    val bookmark = bookmarkViewToBookmark(bookmarkView)
    // 3
    bookmark?.let { bookmarkRepo.updateBookmark(it) }
  }
}

This method updates the bookmark from a BookmarkDetailsView.

  1. A coroutine is used to run the method in the background. This allows calls to be made by the bookmark repo that accesses the database.
  2. The BookmarkDetailsView is converted to a Bookmark.
  3. If the bookmark is not null, it’s updated in the bookmark repo. This updates the bookmark in the database.

Now you can modify the bookmark details Activity and make use of the new updateBookmark() method provided by the View model.

Open BookmarkDetailsActivity.kt and add the following method:

private fun saveChanges() {
  val name = databinding.editTextName.text.toString()
  if (name.isEmpty()) {
    return
  }
  bookmarkDetailsView?.let { bookmarkView ->
    bookmarkView.name = databinding.editTextName.text.toString()
    bookmarkView.notes = databinding.editTextNotes.text.toString()
    bookmarkView.address = databinding.editTextAddress.text.toString()
    bookmarkView.phone = databinding.editTextPhone.text.toString()
    bookmarkDetailsViewModel.updateBookmark(bookmarkView)
  }
  finish()
}

This method takes the current changes from the text fields and updates the bookmark. The method doesn’t do anything if editTextName is blank. After updating the bookmarkView with the data from the EditText fields, updateBookmark() is called to update the bookmark model. Finally, the Activity is closed with the finish() call.

Next, you need to add code to respond to the user tapping the checkmark menu item and then calling saveChanges().

Add the following method:

override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
  R.id.action_save -> {
    saveChanges()
    true
  }
  else -> super.onOptionsItemSelected(item)
}

This method is called when the user selects a Toolbar checkmark item. You check the item.itemId to see if it matches action_save, and if so, saveChanges() is called.

Build and run the app. Go into the details Activity of an existing bookmark and change some of the data. Tap the checkmark in the Toolbar to save your changes. Now, display the details for the same bookmark, and you’ll see that the data reflects your changes.

Key Points

Placebook is starting to take shape. In this chapter you learned:

  • Saving images can be done using ByteArrayOutputStreams.

  • Loading images is done with BitmapFactory.decodeFile.

  • Data Binding is used to set data in layouts.

  • ViewModels are used for retrieving data and maintaining state.

  • ConstraintLayout is useful for complex layouts.

  • Styles are useful for defining reusable Text & View properties.

Where to go from here?

Congratulations! You can now edit bookmarks, but there’s still more work to do. The next chapter wraps things up by adding some additional features and putting the finishing touches on the app.

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