Skip to content

Advanced UI

Malachi de AElfweald edited this page Dec 10, 2018 · 42 revisions

Goal

Add some common UI elements, learn about databinding and multi-page applications.

Table of Contents

Up until now, our application has still resembled a Hello World app. Throughout this chapter, you will transform it into our Reading List application.

Enable Databinding

Open your module-level build.gradle and add a new dataBinding closure inside the android closure. Remember that it is case sensitive.

android {
    dataBinding {
        enabled = true
    }
}

Sync your application.

Layout Changes

The most obvious change you will see initially is around the layout files. Let's take our existing layout and do a minimal conversion for it.

Open your activity_book_list.xml.

Currently, it looks like this:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        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"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/black"
        tools:context=".BookListActivity">
...your text and image views...
</androidx.constraintlayout.widget.ConstraintLayout>

We are going to make a new top-level element, and this ConstraintLayout will become a child of it (remember, only one top-level element can be present).

The databound format looks like:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns-from-original-layout-here>
    <data>
        
    </data>
    <your original layout here... />
</layout>
  • Select everything (except the <?xml ... ?> line) and Ctrl+X to cut it.
  • Add a new <layout></layout> element and press enter between the start and end tags.
  • Add a new <data></data> element inside of that; and press enter between those start and end tags.
  • Between </data> and </layout> use Ctrl+V to paste the old layout back inside.
  • Highlight all the xmlns attributes on the ConstraintLayout and cut them
  • Go inside the <layout> element and press space or enter. Paste the xmlns attributes (like <layout xmlns...)

When we are done, the end result looks like this:

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

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/black"
        tools:context=".BookListActivity">
...your text and image views...
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Make sure app is selected and redeploy your application. Let's make sure we haven't broken anything yet.

Congratulations! You've just auto-generated your first databound model.

findViewById

So what can it do for us?

Remember our conversation about findViewById? The way that function works is by iterating over all your views until it finds the one with the correct identifier. When your application calls that function over and over and over again, you introduce a lot of lag.

We can improve the speed and readability at the same time using databinding.

When we modified that layout, we instructed the build to auto-generate a new class, ActivityBookListBinding (based on activity_book_list.xml). This auto-generated class acts as a view holder for any variables defined in the layout and keeps a list of all the ids we specified.

If you Navigate | Class and enter ActivityBookListBinding you will see the newly auto-generated class. You will also notice a few familiar public final variables at the top.

For now, close that class and just remember that re-deploying our application re-generates those files.

Let's see how we can use it.

Open your BookListActivity. First we are going to add a new variable

private lateinit var binding: ActivityBookListBinding

If you try to copy/paste that line and it doesn't recognize the class, remove it and try typing it out instead.

The lateinit tells the system that we will give it a value later. It's not set yet, but we can not set it to null.

Then, in our onCreate, replace this line

setContentView(R.layout.activity_book_list)

with

binding = DataBindingUtil.setContentView(this, R.layout.activity_book_list)

While we didn't really have to save that binding for later, it can come in really handy.

Now, our first findViewById looks like this:

findViewById<TextView>(R.id.hello_text).text = getBooksLoadedMessage(it.books.size)

We are going to replace that line with

binding.helloText.text = getBooksLoadedMessage(it.books.size)

Notice that it replaced the underscores with camel cases.

Let's do the next one. Replace

findViewById<TextView>(R.id.login_text).text = resources.getString(R.string.last_login, displayFormat.format(time))

with

binding.loginText.text = resources.getString(R.string.last_login, displayFormat.format(time))

If you ever catch yourself writing findViewById, stop and ask yourself if it could be databound instead.

Optimize your imports and re-deploy to validate that everything is still working.

RecyclerView

It's time to try to do something more useful with our application.

A RecyclerView is like a list, but when rows are off-screen, it removes them from memory. As such, they suffer less from large amounts of data than a normal list would.

There are multiple parts to setting up a RecyclerView. While it is one of the more complicated UI elements, it is also one of the most used.

RecyclerView.ViewHolder

The first thing a RecyclerView needs is an extension of RecyclerView.ViewHolder to hold references to each view.

But wait... doesn't our auto-generated binding class already do that?

It does, but it doesn't extend the class required by the framework. However, we can cheat. Since we know that everything we need will be auto-generated, we can create a small proxy class that will wrap the binding class and just look like a ViewHolder. The beauty of this approach is that we only need one no matter how many different ViewHolders the framework expects.

Right-click on the readinglist package ( com version) and let's make a new subpackage called ui. It should be a sibling to your model package.

In it, create a new class called DataboundViewHolder. We are going to rely on the fact that we are using Databinding to create a generic ViewModel.

package com.aboutobjects.curriculum.readinglist.ui

import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.annotation.NonNull
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.RecyclerView

class DataboundViewHolder<BINDING : ViewDataBinding> : RecyclerView.ViewHolder {
    val binding: BINDING
    fun context(): Context = binding.root.context

    constructor(binding: BINDING) : super(binding.root) {
        this.binding = binding
    }

    constructor(@LayoutRes layoutId: Int, @NonNull parent: ViewGroup)
            : this(
        DataBindingUtil.inflate<BINDING>(
            LayoutInflater.from(parent.context),
            layoutId,
            parent,
            false
        )
    )
}

Although short, it looks a little complicated. Let's walk through it.

First, the DataboundViewHolder class has a generic defined. Normally, you see these as simply T. We are using BINDING here so it is more obvious when using it. That parameter references the auto-generated binding class, like ActivityBookListBinding from earlier.

Next, we specify a val (not var) to store the binding variable. This is possible, even though it is not set on that line, because it is set from the constructors. If you had a constructor that did not set it, you would not be able to do this.

We then specify a convenience function for anyone using this class to get the Context associated with the root view. This could be a DecorView or some other class, but should be sufficient for things like getString.

We then specify two constructors. The first constructor takes an already constructed binding class (like ActivityBookListBinding) and just passes it in.

The second constructor is a little more complicated. It asks for the layout id and parent, inflates the xml, creates the binding class, then continues as normal.

Now, how do we use this?

Reading List UI

Since it requires us to specify the layout id, maybe we should create the layout next?

Open your Book model and let's re-review.

package com.aboutobjects.curriculum.readinglist.model

data class Book(
    val title: String? = null,
    val author: Author? = null,
    val year: String? = null
)

Each row in our new UI will represent a book, and thus the information in our Book model - title, author and year.

New Layout

Right-click on your res/layout folder and New | Layout resource file.

New Layout Config

Since we are creating a layout that represents a single book, we want to name it as such. We will use item_ to represent single rows since we could end up with different types over time. How about item_book.xml?

There are different types of layouts available, however we are going to be using databinding anyway. For the 'Root element' put layout. Note that it will provide suggestions as you type.

At this stage, we are only using the main source set; but this is where you would specify that a layout is only for your debug build, for example.

While we are not going to add any qualifiers at this time, it is a good opportunity for you to learn about them. Find the Orientation in the list and click it. Then click the >> button. If it is too collapsed to make out, you can drag the bottom right corner of the window to make it larger. You will notice that as you change between Portrait and Landscape, it changes the Directory name to layout-land and layout-port. Click the '<<' to get rid of the qualifier and your directory should go back to layout. In this way you can specify that your UI looks different in different orientations, on different screen sizes, in different countries, on different mobile carriers, or even on different versions of Android. You can play around with these. When you are done, make sure none are selected for this exercise.

With Directory name set to layout go ahead and click OK.

Design View

You will notice that it starts in the Design view. If it didn't, click on the Design tab now.

Designing with Blueprints

While we did all of our editing through the text editor earlier, we will use the Designer this time.

On the top-left of the designer, you will see a section called "Palette" that is divided into two columns. Below that is a "Component Tree". On the right side of the designer is the "Attributes". There is also a small toolbar above the designer. The designer itself is split into two views, the Design and the Blueprint.

In the Palette, select Layouts from the first column and ConstraintLayout in the second column. Now, click-drag the ConstraintLayout into the large white section of the Design view. The first thing you will notice is that there is now a border around both the Design and Blueprint. The attributes now have some information in them. The Component Tree now shows that the Constraint Layout is a child of the root layout element.

In the same way, drag a Text|TextView into the top-left corner of the white section of the Design view. You can leave a little bit of padding to make it look nice. Like before, the Component Tree now shows the TextView is a child of the Constraint Layout.

The Attributes view has been updated to reflect the information about this particular view. Let's tweak some of that.

Fake Title

Let's change the ID from textView to title. Let's also temporarily change the text from TextView to A Fake Book Title (or it will auto-collapse and be hard to adjust). Where you see textSize, change that from 14sp to 18sp. Lastly, click on the little "Bold" symbol.

In a similar way, let's create another TextView below it. This one will have the ID author, text will be Fake Author. We'll leave the size at 14sp but click on "Italic".

Note that the designer will show guidelines to help you line things up.

Lastly, make one more TextView next to the author. Give it an ID year, set the text to 1999, leave it at 14sp and do not click either bold or italic.

Laid Out

If you look at the Component Tree, you will notice a red exclamation mark on each of the new views. Hovering your mouse over these will tell you that This view is not constrained. It only has designtime positions, so it will jump to (0,0) at runtime unless you add the constraints

Let's do that.

Click on your A Fake Book Title view. In the design toolbar, you will see some icons.

Toolbar

First, you will see something that looks like a magnet. It has a tooltip about "Autoconnect". If it has a line through it, then click it once to remove the line and enable auto-connect.

The other icon looks like a magic wand shooting sparks, with a tooltip that says "Infer Constraints". With your text view selected, click that icon.

You might have also noticed that the ConstraintLayout takes up the entire screen real estate. In reality, if this is just one row, we need that to collapse down. Click on the white background (which selects the Constraint Layout in the Component Tree) and change the layout_height in the Attributes panel to wrap_content.

That looks better? It would be nice if the top margin and bottom margin matched though...

If you click on the title view, you will see in the Attributes panel that it has a 16 top margin.

Before Margin After Margin

The author view has a left margin, but no bottom margin. Click that + sign which will give you a new drop-down box. Change it from 0 to 16. Do the same for the year view.

Margin Fix

In your Component Tree view, you will notice that the red exclamations have been replaced with yellow warnings. The new warning is telling us that we should be using @string resources, as we learned earlier.

It's time to switch back to the Text view and do some final editing.

You will notice that those three strings are highlighted by the IDE as needing attention. We don't ever want to show those values to the end-user. What we DO want, however, is to continue showing them in the designer.

On those three views, change the android:text to tools:text.

The highlights will go away, indicating that the problem has been dealt with. In the design view, everything looks the same, except the warnings are now gone as well.

Back in the Text view, inside the <layout> tag, but above our <ConstraintLayout> tag, add in our empty data block:

<data>
</data>

End result should look like this:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <data>

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginTop="16dp"
            tools:text="A Fake Book Title"
            android:textSize="18sp"
            android:textStyle="bold"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/author"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginBottom="16dp"
            tools:text="Fake Author"
            android:textStyle="italic"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent" />

        <TextView
            android:id="@+id/year"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="11dp"
            android:layout_marginTop="8dp"
            android:layout_marginBottom="16dp"
            tools:text="1999"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toEndOf="@+id/author"
            app:layout_constraintTop_toBottomOf="@+id/title" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View Models

Currently, if we wanted to populate this layout we would need to set the title, author and year. Wouldn't it be nice if we could just set the Book model instead?

We can.

Inside the data block, let's specify a variable. Remember, auto-completion is your friend here.

<data>
    <variable
        name="book"
        type="com.aboutobjects.curriculum.readinglist.model.Book" />
</data>

Note that the type is fully-qualified

Now, inside the title view, add a new attribute

android:text="@{book.title}"

The @{...} format tells the framework to execute that instruction; thus, it gets the title from the book variable. Technically, if you are familiar with Java, it is calling book.getTitle(). Kotlin in our model just hides that syntax.

Do similar with the author and year.

Now, we can just set the book and everything else should fall into line.

Adapter

The next step is to create our Adapter. Create a new class in our ui package called ReadingListAdapter. It has to extend RecyclerView.Adapter<T>, but we have our new DataboundViewHolder so what we end up with is:

package com.aboutobjects.curriculum.readinglist.ui

import androidx.recyclerview.widget.RecyclerView
import com.aboutobjects.curriculum.readinglist.databinding.ItemBookBinding

class ReadingListAdapter : RecyclerView.Adapter<DataboundViewHolder<ItemBookBinding>>() {
}

When you extend RecyclerView.Adapter, you will notice that your new ReadingListAdapter is underlined in red. Move your mouse over it and it will tell you that your class is not abstract and it doesn't implement certain functions.

Use the IDE to help us. Click on ReadingListAdapter and Alt+Enter (or Option+Enter on Mac) to use the IDE quick fix.

IDE QuickFix

Select all of the method it recommends and check the Copy JavaDoc then click OK.

Take a moment to look at what was generated.

Before we replace the TODOs that were created, we need some data to work with. Our adapter will use our ReadingList model that we created earlier.

At the top of the class, let's add a new variable to hold that model.

var readingList: ReadingList? = null
    set(value) {
        field = value
        notifyDataSetChanged()
    }

Here, we set it to null by default since it is not loaded yet. Then, whenever the data is set, we notify listeners that the data has changed.

getItemCount

Now, looking at our three TODO, it seems that the getItemCount might be the easiest? We are just going to return the number of books in our list.

We can replace

override fun getItemCount(): Int {
    TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}

with

override fun getItemCount(): Int {
    return readingList?.books.orEmpty().size
}

And actually, we can even reduce it a little more...

override fun getItemCount(): Int = readingList?.books.orEmpty().size

onCreateViewHolder

What about the onCreateViewHolder? This method is used to create a new view holder for each row.

For this one, we want to take an extra preparatory step.

Let's add another method first. Using Ctrl+O for Override, double click on getItemViewType.

What we want is a unique integer that represents how we want to display the item at a specific index. What better way than to return the layout resource id?

For now, we only have one row type, so just return it.

    override fun getItemViewType(position: Int): Int =  R.layout.item_book

Later, we will look at how to intermix different types.

Now, back to onCreateViewHolder. The viewType passed into that method will now be our layout id, which means we can do this:

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataboundViewHolder<ItemBookBinding> {
    return DataboundViewHolder(layoutId = viewType, parent = parent)
}

onBindViewHolder

That leaves the onBindViewHolder. This one takes the view holder created above and binds our data to it.

override fun onBindViewHolder(holder: DataboundViewHolder<ItemBookBinding>, position: Int) {
    readingList?.let {
        holder.binding.book = it.books[position]
    } // else... what do we do?
}

If the reading list is loaded, we get the specified book model and set it on the binding class that was auto-generated; which will in turn set the title, author and year.

I left a question there for us to evaluate once the new UI is deployed to the emulator.

Setting up the RecyclerView

Now that all the pieces are in place, it's time for us to actually use them.

Open your activity_book_list.xml and replace the ImageView with the new RecyclerView:

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

    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/black"
        tools:context=".BookListActivity">

        <TextView
            android:id="@+id/hello_text"
            style="@style/LightTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/hello"
            app:layout_constraintBottom_toTopOf="@+id/login_text"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

        <TextView
            android:id="@+id/login_text"
            style="@style/LightInfo"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:text="@string/last_login"
            app:layout_constraintBottom_toTopOf="@+id/recycler"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/hello_text"/>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler"
            android:background="@color/cornsilk"
            android:scrollbars="vertical"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/login_text"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

We borrow the constraints from the old ImageView, and make sure to adjust the constraint on the login text to point to the new recycler.

layout_height of 0dp tells it to use the remaining space.

Try to deploy. Doesn't matter if it succeeds or not - you are doing this step to get the compiler to regenerate the binding class.

Open your BookListActivity.

First, add two more variables

private val viewAdapter = ReadingListAdapter()
private lateinit var viewManager: RecyclerView.LayoutManager

We are able to define the adapter already because we chose to allowing setting the ReadingList in it after the fact.

Now, in our onCreate, immediately after we define binding let's define the viewManager and setup our recyclerView.

viewManager = LinearLayoutManager(this)
binding.recycler.apply {
    setHasFixedSize(true)
    layoutManager = viewManager
    adapter = viewAdapter
}

The other common layout manager is the GridLayoutManager; but LinearLayoutManager is by far the most commonly used.

Lastly, inside our loadJson()?.let { closure, call

viewAdapter.readingList = it

That will update our adapter (and notify any listeners) once the data is loaded.

Debugging Databinding

Try to deploy the app.

You will notice an error that looks like this

Found data binding errors.
****/ data binding error ****msg:Cannot find the setter for attribute 'android:text' with parameter type com.aboutobjects.curriculum.readinglist.model.Author on android.widget.TextView. file:/home/malachid/work/curriculum/ReadingList/app/src/main/res/layout/item_book.xml loc:34:28 - 34:38 ****\ data binding error ****

So what does this mean?

It says that you are calling android:text on a TextView, but passing an Author model. An Author model is not a String, so it doesn't know what to do.

There are a couple options on how to address this. If the object you are referencing is a simple one, you might get away to toString(), but in our case that wouldn't look very good.

You can also use BindingAdapters so that the system knows how to use Author models in TextView classes. While this can seem really tempting, it is best to leave those to things that are used often (like converting colors or urls, etc). Otherwise it starts to grow out of hand very quickly.

Another option for us is to write a function that handles it specifically. These are often contained in the view model classes, or within extension functions.

We'll take a simpler approach.

Let's update our Author model to have a displayName function.

package com.aboutobjects.curriculum.readinglist.model

data class Author(
    val firstName: String? = null,
    val lastName: String? = null
) {
    companion object {
        const val UNKNOWN = "Unknown"
    }

    fun displayName(): String? {
        return when {
            firstName == null && lastName == null -> UNKNOWN
            firstName == null -> lastName
            lastName == null -> firstName
            else -> "$firstName $lastName"
        }
    }
}

Then in our item_book.xml and change the author binding to

android:text="@{book.author.displayName()}"

Deploy the application.

Adapter Populated

Divider

That's starting to look a lot better. Can we add a divider in between the rows?

Edit the recycler closure in our BookListActivity and add a DividerItemDecoration

binding.recycler.apply {
    setHasFixedSize(true)
    layoutManager = viewManager
    addItemDecoration(DividerItemDecoration(this@BookListActivity, DividerItemDecoration.VERTICAL))
    adapter = viewAdapter
}

We specify this@BookListActivity because this refers to the binding.recycler itself.

Redeploy your application.

Adapter with Divider

Multiple View Types

It's starting to look pretty good. Our ReadingList model also has its' own title. Maybe we can add that to our list?

To do that, we are going to update our ReadingListAdapter to handle more than one type of row.

Like before, let's start with defining the layout for the new row. Let's call it item_title.xml with a layout Root element.

Add a TextView and we'll use our ReadingList model as the variable.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="readinglist"
            type="com.aboutobjects.curriculum.readinglist.model.ReadingList" />
    </data>

    <TextView
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@{readinglist.title}"
        tools:text="ReadingList Title"
        android:textSize="24sp"
        android:textStyle="bold" />
</layout>

Next we will make some tweaks to our ReadingListAdapter.

First, we will now be referencing both ItemBookBinding and ItemTitleBinding, so change our class signature to be more generic:

class ReadingListAdapter : RecyclerView.Adapter<DataboundViewHolder<ViewDataBinding>>() {

Now, starting with getItemCount, we know that we will have 1 extra element for the title.

override fun getItemCount(): Int {
    return readingList?.let { 
        it.books.size + 1 // +1 for title
    } ?: 0 // no title if not loaded
} 

Next we look at getItemViewType. We know that the first (item 0) will be the title and that any other item will be a book.

override fun getItemViewType(position: Int): Int {
    return when(position) {
        0 -> R.layout.item_title
        else -> R.layout.item_book
    }
}

Since we are using the getItemViewType to specify the layout id, the onCreateViewHolder doesn't have to change anything but the signature.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataboundViewHolder<ViewDataBinding> {
    return DataboundViewHolder(layoutId = viewType, parent = parent)
}

The onBindViewHolder has to change both the signature and the implementation.

override fun onBindViewHolder(holder: DataboundViewHolder<ViewDataBinding>, position: Int) {
    readingList?.let {
        when(holder.binding) {
            is ItemTitleBinding -> holder.binding.readinglist = it
            is ItemBookBinding -> holder.binding.book = it.books[position - 1]
            else -> {
                // @TODO add logging
            }
        }
    } // else... what do we do?
}

Here, we are relying on our DataboundViewHolder having knowledge of what type it is. We could use the position index like in getItemViewType, but why duplicate logic?

Also, the IDE is telling you that is ItemBookBinding is always true. So why not just make that row the else instead of having the TODO section? Down the road, if you were to add additional types (say, Magazines) this approach would help catch a bug that would cause it to be shown with the wrong layout.

Note that we changed the array position from position to position - 1 to account for position 0 now being the title.

We'll get to how to add logging in the next chapter.

Deploy your application.

Multiple View Types

Cleanup

It's starting to look a lot better... let's remove our early work from the top of the page.

activity_book_list.xml

Update your activity_book_list.xml

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

    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/cornsilk"
        tools:context=".BookListActivity">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler"
            android:scrollbars="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Try (unsuccessfully) to deploy your application so that it re-generates the ActivityBookListBinding.

BookListActivity.kt

Open the BookListActivity. You'll notice that the helloText and loginText are now red. That's because these fields are no longer available.

Remove:

binding.helloText.text = getBooksLoadedMessage(it.books.size)

Since we are no longer showing the timestamp, you can also remove:

prefs.getString(KEY_TIMESTAMP, null)?.let {
    val time = timestampFormat.parse(it)
    binding.loginText.text = resources.getString(R.string.last_login, displayFormat.format(time))
}

prefs.edit {
    putString(KEY_TIMESTAMP, timestamp())
}

Now, scrolling up to the top of the page, you will notice some variables (like timestamp) are grayed out. That's because they are no longer in use. Let's remove some more. Remove these.

private val displayFormat: SimpleDateFormat by lazy {
    SimpleDateFormat(displayPattern, Locale.getDefault())
}

private fun timestamp(): String {
    return timestampFormat.format(Calendar.getInstance().time)
}

private val prefs: SharedPreferences by lazy {
    getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
}

More is grayed out, remove this:

private val timestampFormat: SimpleDateFormat by lazy {
    // Don't set Locale.getDefault() in companion because user may change it at runtime
    SimpleDateFormat(timestampPattern, Locale.getDefault())
}

If you look at the companion object some of those variables are also grayed out. Remove these.

// from https://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html
const val timestampPattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"
const val displayPattern = "yyyy.MM.dd 'at' HH:mm:ss z"
val KEY_TIMESTAMP = "${BookListActivity::class.java.name}::timestamp"

We're almost done with this file.

Your imports are probably collapsed to look like import .... Click on those dots to expand that section. You'll notice quite a few of those imports are also no longer required. We'll take a shortcut to clean those up. Use Ctrl+Alt+O (or Ctrl+Option+O on Mac) to Optimize Imports.

Let's do one last piece of cleanup while we are here.

You'll notice that we have loadJson as its' own method, but the save json logic is inline. Let' move that out.

First, we will copy the logic from onCreate into a new method

private fun saveJson(readingList: ReadingList) {
    try{
        val writer = FileWriter(File(filesDir, JSON_FILE))
        app.gson.toJson(readingList, writer)
        writer.flush()
        writer.close()
    }catch(e: Exception){
        // @TODO introduce logging
    }
}

Then update our onCreate

loadJson()?.let {
    viewAdapter.readingList = it
    saveJson(it)
}

We can deploy our application successfully now, and it's looking a lot better. But we are missing something. Do you know what it is?

Cleanup

Unit Tests

Right-click on your com (test) folder and Run 'Tests in 'com''.

Good news, they all pass.

You should make a habit of running these tests regularly.

Integration and UI Tests

Right-click on your com (androidTest) folder and Run 'Tests in 'com''.

/home/malachid/work/curriculum/ReadingList/app/src/androidTest/java/com/aboutobjects/curriculum/readinglist/BookListActivityUITests.kt: (31, 28): Unresolved reference: login_text

Our UI tests made assumptions that login_text existed - but it no longer does.

Open BookListActivityUITests. You'll notice that, like in our BookListActivity, login_text is now red because it no longer exists.

Although not a very useful test, let's replace lastLogin_isDisplayed with one that will pass for now

@Test
fun recycler_isDisplayed() {
    val scenario = ActivityScenario.launch(BookListActivity::class.java)

    onView(withId(R.id.recycler))
        .check(matches(isDisplayed()))
}

Run your tests and verify everything passes now.

Click Handling

Now that we can see a list of books, it would be nice if we could click on one. Maybe to edit it?

While it is possible to use a generic click handler on UI elements; with databinding we can make it a bit more manageable.

Let's start by defining a new interface to be used when a Book model is clicked.

BookClickListener

Right-click on your ui package and select New | Kotlin File/Class and name it BookClickListener. We then define the callback method we want to be used.

package com.aboutobjects.curriculum.readinglist.ui

import com.aboutobjects.curriculum.readinglist.model.Book

class BookClickListener(val bookClicked: (Book) -> Unit) {
    fun onBookClicked(book: Book) {
        bookClicked.invoke(book)
    }
}

The weird constructor here allows us to pass a Kotlin lambda function. Similar redirects can be used to wrap generics, for example, to simplify the definitions in the XML.

item_book.xml

We'll update our xml to use the callback.

Add a new variable in the data block

<variable
    name="listener"
    type="com.aboutobjects.curriculum.readinglist.ui.BookClickListener" />

Then we will update our layout to add the focusable,clickable and finally onClick.

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:focusable="true"
    android:clickable="true"
    android:onClick="@{() -> listener.onBookClicked(book)}">

Note, if we only wanted access to the view and not the book, there is a much simpler way of doing it using method references, but the Book model is what really matters to us.

Redeploy your application to make sure the binding classes are re-generated.

ReadingListAdapter.kt

Our adapter now needs to be updated to populate the new listener variable.

First, we'll update our class definition to take a lambda in the constructor.

class ReadingListAdapter(
    val bookClicked: (Book) -> Unit
) : RecyclerView.Adapter<DataboundViewHolder<ViewDataBinding>>() {

And then update the onBindViewHolder for the book case (we don't currently allow clicking on the title)

is ItemBookBinding -> {
    holder.binding.book = it.books[position - 1]
    holder.binding.listener = BookClickListener(bookClicked)
}

BookListActivity

Now, the our BookListActivity will actually do something when the book is clicked. You are probably wondering why all the levels of indirection? Each piece of the puzzle has their own job to do, and we don't want any one class becoming either a kitchen sink or a bottleneck. Anotherwords, only ask those classes to do what they were meant to do. The ReadingListAdapter is only meant to select which layout to show.

If you look closely at our line that says private val viewAdapter = ReadingListAdapter() you will notice that there is now a red mark in the parens. Hovering over it will tell you that No value passed for parameter 'bookClicked'.

Let's do that now.

private val viewAdapter = ReadingListAdapter(
    bookClicked = {
        // @TODO do something with `it`
    }
)

We need to create a new screen to launch when the book is clicked.

For now we will have the click launch a new Activity. When we get to the Fragments chapter, we will update this code to use Fragments instead.

EditBookActivity

Make a new class in the same package as BookListActivity called EditBookActivity. Like our BookListActivity, it will also extend AppCompatActivity.

package com.aboutobjects.curriculum.readinglist

import androidx.appcompat.app.AppCompatActivity

class EditBookActivity: AppCompatActivity() {
}

You'll notice that EditBookActivity is highlighted. Hovering over it tells you that it is not in the manifest. Click on EditBookActivity once. You'll see a lightbulb appear to the left of the editor. Click it then choose Add activity to manifest.

Note: You could have used the Quick Fix shortcut (Alt+Enter or Option+Enter on Mac) instead of clicking the lightbulb.

If you look at your AndroidManifest.xml you will notice that it added

<activity android:name=".EditBookActivity" />

It did not, however, add the MAIN LAUNCHER intent filter, which is why it doesn't look like an additional application in your app drawer.

activity_edit_book.xml

We'll need a new UI to edit the book. We have a couple options on how to do the new layout. We could mimic what we have just done and create a new item type for each piece of information (title, author, year) that we want to edit.

Let's use the Designer for quick prototyping.

Start by creating another layout file activity_edit_book.xml with a layout root element (because we are using databinding).

We know we want to be able to edit 3 fields. We probably also want labels for those 3 fields. So, we probably want 3 rows with 2 columns each.

Let's try adding a Layouts | TableLayout. Now, although there are TableRows automatically added, they may not show up in the designer. If that's the case, go ahead and drop a Layouts | TableRow onto the TableLayout in the Component Tree. That should force them to expand.

Drag a Text | TextView onto the first TableRow in the Component Tree. Do the same thing to the second and third TableRow.

Click on the 4th TableRow and click your delete button. Do the same for the 5th one.

Now, drag a Text | Plain Text onto the textView in the first TableRow. Do the same thing to the other two.

Now we are going to go down the list and start styling.

Click the textView in the first row, give it an id of title_label, set the text to Title, make it 18sp and Bold.

The second one will be author_label and Author. The third year_label and Year.

Now, the editText entries.

The first one will be title. Change the inputType to text (make sure to unselect any other options). Remove the value in the text field.

Do the same for the author and year fields, except make the year field a number instead of text.

Edit UI

Back in the Text view, add our empty data block

<data>
    
</data>

You'll notice our warning about the three strings. Let's fix that with the Quick Fix (either the keyboard shortcut or the lightbulb) by clicking in the string itself (ie inside the word Title, not just anywhere on the line) and choosing Extract string resource.

We'll give them reasonable resource names that won't conflict down the line, like book_title_label.

Extract String

When you have fixed all three strings, rebuild to regenerate the binding classes.

Back to EditBookActivity

Update the EditBookActivity to show the new UI.

private lateinit var binding: ActivityEditBookBinding

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = DataBindingUtil.setContentView(this, R.layout.activity_edit_book)
}

Remember, if you are copy/pasting the binding class may not auto-complete. If that is the case, just type that class name.

Now, how are we going to tell it which book to display information about? We need some way to pass the Book model from our BookListActivity to our EditBookActivity.

We will add a function to our companion object that can be called statically (before we create the EditBookActivity instance).

companion object {
    val EXTRA_BOOK = "${EditBookActivity::class.java}.name::book"

    fun getIntent(context: Context, book: Book): Intent {
        val gson = (context.applicationContext as ReadingListApp).gson
        return Intent(context, EditBookActivity::class.java).apply {
            putExtra(EXTRA_BOOK, gson.toJson(book))
        }
    }
}

Here, we ask the caller to provide their context (because we do not have one yet) and the Book they would like to edit. Since the Intent doesn't know how to handle that type of model, we are converting it to something it can handle, in this case a json string.

You could also convert it to a Parcelable or a Serializable, if that would be more convenient for you -- but since we have already examined how to do this conversion, we'll use it.

We need to update our BookListActivity to call it

private val viewAdapter = ReadingListAdapter(
    bookClicked = {
        startActivity(EditBookActivity.getIntent(
            context = this,
            book = it
        ))
    }
)

Note that in the IDE, the bookClicked = { line also includes a hint that says it: Book.

That will suffice to call the new edit activity. But we still need to consume it.

Back in the EditBookActivity we add a new variable to read the new parameter from the intent.

private val extraBook: String? by lazy { intent?.getStringExtra(EXTRA_BOOK) }

That, of course, is still reading it as a String.

Add our convenience variable from before

private val app: ReadingListApp by lazy { application as ReadingListApp }

Then in onCreate we can do

extraBook?.let {
    val book = app.gson.fromJson(it, Book::class.java)
    // @TODO use the book
}

We could just bind that book to our xml like before, but then it is just readable. We want it to be editable. How are we going to do that?

Two-Way Databinding with EditText

What we have done so far is one-way databinding. We let the XML view the data in our model.

What we want to do now is also have the model update when the data in the UI changes. We call that two-way databinding.

EditableBook

In our edit UI, we have three fields (title, author, year) that are editable. Let's create a view model to handle this.

In your model package, create a new class called EditableBook. We'll seed it with the Book we want to edit.

package com.aboutobjects.curriculum.readinglist.model

class EditableBook(val source: Book) {
}

Now, we need to mimic the three pieces of data that we want to have edited. This will be especially interesting for the Author field because we have a single text input for two Book.Author fields.

To do this, we are going to use ObservableField. The Android databinding library has a few handy classes to use when you want to allow the XML to observe data changes. By far, the most useful one is ObservableField which allows us to specify a generic, like String as the type.

The ObservableField variables themselves will never change - the contents inside those variables will, so we will use val instead of var.

We will also initialize them with values from our source Book.

package com.aboutobjects.curriculum.readinglist.model

import androidx.databinding.ObservableField

class EditableBook(val source: Book) {
    val title = ObservableField<String>(source.title)
    val author = ObservableField<String>(source.author?.displayName())
    val year = ObservableField<String>(source.year)
}

activity_edit_book.xml

Add a EditableBook variable to the activity_edit_book.xml called book.

<data>
    <variable
        name="book"
        type="com.aboutobjects.curriculum.readinglist.model.EditableBook" />
</data>

Like before, we will update the android:text value for each of the EditText classes; however, this time we will add an equals sign to signify two-way databinding.

android:text="@={book.title}"
...
android:text="@={book.author}"
...
android:text="@={book.year}"

Rebuild to regenerate the binding classes.

EditBookActivity

EditBookActivity can now be updated to bind the new EditableBook model.

Let's update our onCreate

extraBook?.let {
    binding.book = EditableBook(
        source = app.gson.fromJson(it, Book::class.java)
    )
}

Rebuild and deploy your application.

Click on one of the books.

Two-way DB

Saving Changes

It now populates and lets you make changes; but those changes are lost when you leave the screen.

We could automatically persist any changes you make as soon as you make them, but then how would you change your mind or cancel?

A more intuitive approach might be to have a save button, and continue to allow leaving without saving to result in cancellation.

To add a save button we have a few choices. A common approach might be to add a big button at the bottom of the page. A less disruptive approach might be to add something to the toolbar. Let's go with that approach.

menu_edit_book.xml

Menu Folder 1

Right-click on your res folder and select New | Android Resource Directory.

Menu Folder 2

On the next screen, change the Resource type from values to menu.

Note: We could specify qualifiers here as well.

Click OK.

Menu Resource 1

Right-click on the new menu folder and select New | Menu resource file.

Menu Resource 2

Name it menu_edit_book.xml and click OK.

Add the following <item/> to your new menu.

<?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">
    <item
        android:id="@+id/action_save"
        android:icon="@android:drawable/ic_menu_save"
        android:title="Save"
        app:showAsAction="always" />
</menu>

The icon is built into Android. If you don't have your own, a good starting place is the ones that start with ic_.

Let the IDE help you convert the hardcoded Save text into a string resource named save, resulting in:

<?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">
    <item
        android:id="@+id/action_save"
        android:icon="@android:drawable/ic_menu_save"
        android:title="@string/save"
        app:showAsAction="always" />
</menu>

EditBookActivity

To make your new menu show up, edit your EditBookActivity.

Use the Override command (Code | Override Methods in the toolbar, or Ctrl+O) to implement onCreateOptionsMenu.

override fun onCreateOptionsMenu(menu: Menu?): Boolean {
    menuInflater.inflate(R.menu.menu_edit_book, menu)
    return true
}

The R.menu just means the res/menu folder, and menu_edit_book is the name of our xml. The last menu is the parent to add it to, which was passed into the function.

Redeploy your application. When you go to the edit screen, you'll now see a save icon. It doesn't do anything yet, but it is there.

For that, we need to override another method.

override fun onOptionsItemSelected(item: MenuItem?): Boolean {
    return when(item?.itemId) {
        R.id.action_save -> {
            // @TODO do something
            true
        }
        else -> super.onOptionsItemSelected(item)
    }
}

Here we are referencing the android:id we set in the menu file. We return true to signify that we handled the event. If we didn't, we rely on the underlying framework to make a decision.

So... what should we do when it is clicked?

We want to take the EditableBook and convert it to a Book model, and return it back to the calling activity, which has a reference to the entire reading list.

Converting the title and year should be no problem. How are we going to convert the Author? We have a single string but the Author object takes two.

Author

What we can do is build that business logic into the companion object of the Author model itself, which can then be used to create instances of the Author class.

Open your Author class. We will add a new function inside the companion object

fun from(name: String?): Author {
    return when {
        name.isNullOrEmpty()-> Author()
        name.contains(",") -> {
            val index = name.indexOf(",")
            Author(
                firstName = name.substring(index + 1),
                lastName = name.substring(0, index)
            )
        }
        name.contains(" ") -> {
            val index = name.indexOf(" ")
            Author(
                firstName = name.substring(0, index),
                lastName = name.substring(index + 1)
            )
        }
        else -> Author(lastName = name)
    }
}

Are there any missing use cases? What would happen if the comma was at the end of the name? This is a very good example of something you might want to write unit tests for. There is no need for integration or ui tests here -- you just want to validate the business logic.

EditableBook

Let's add one more convenience for ourselves. Open EditableBook. Add a new function

fun edited(): Book {
    return Book(
        title = title.get(),
        author = Author.from(author.get()),
        year = year.get()
    )
}

What we are doing here is converting the ObservableField values into a new Book model and returning it. The ObservableField class has a set(value) and a get(): value function which we are utilizing. We are also using our new Author.from(name) function that we just created.

While we could listen for changes and automatically re-create an edited book, that could cause many unnecessary models. This function is only called once, during save, which is much more efficient than every time the user types something.

BookListActivity

How do we return a value to BookListActivity?

Open it up and look at our bookClicked method. Instead of startActivity we are going to want to use startActivityForResult. It requires that we specify a request code.

Add a request code to your companion object

const val EDIT_REQUEST_CODE = 1234 

The number can be somewhat arbitrary. Just be aware that it is a 16-bit number, so do not use hashCode() directly.

Now, we can update our bookClicked handler.

private val viewAdapter = ReadingListAdapter(
    bookClicked = {
        startActivityForResult(EditBookActivity.getIntent(
            context = this,
            book = it
        ), EDIT_REQUEST_CODE)
    }
)

Go ahead and redeploy just to make sure nothing has broken.

Back to EditBookActivity

Now we can update our onOptionsItemSelected to return the result.

override fun onOptionsItemSelected(item: MenuItem?): Boolean {
    return when(item?.itemId) {
        R.id.action_save -> {
            binding.book?.let {
                setResult(RESULT_OK, getIntent(
                    context = this,
                    book = it.edited()
                ))
                finish()
            }
            true
        }
        else -> super.onOptionsItemSelected(item)
    }
}

If the binding.book (our EditableBook) is not null, we set the result to OK and call the function we created earlier to serialize it.

This results in the calling activity getting a callback. We should probably write that too.

And back to BookListActivity

Override the method onActivityResult.

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    // We ignore any other request we see
    if (requestCode == EDIT_REQUEST_CODE) {
        // We ignore any canceled result
        if (resultCode == Activity.RESULT_OK) {
            // Grab the book from the data
            data?.getStringExtra(EditBookActivity.EXTRA_BOOK)?.let {
                app.gson.fromJson(it, Book::class.java)?.let { book ->
                    // Grab the old reading list since it is read-only
                    viewAdapter.readingList?.let { oldReadingList ->
                        // Make a writable copy of the books
                        val booklist = oldReadingList.books.toMutableList()
                        // and add the new book to the list -- @TODO what's wrong here?
                        booklist.add(book)
                        // Create a new ReadingList
                        val newReadingList = ReadingList(
                            title = oldReadingList.title,
                            books = booklist
                        )
                        // Save the results
                        saveJson(newReadingList)
                        // And update our view
                        viewAdapter.readingList = newReadingList
                    }
                }
            }

        }
    }
    super.onActivityResult(requestCode, resultCode, data)
}

While it looks overly complex, half of it is comments. We grab the Book that was returned to us and make a new ReadingList model to contain that book. We then save our new list locally and update our UI.

Look through it and see if you know what the problem is at the TODO.

I'll give you a hint. Edit one of the books. Change the title. Hit the save button. Go back through the list and look for your updated book. Did you find it? Did you also find the original?

We added a book, but we didn't remove the original book. How can we know which book to remove?

In general, there are two ways. We either also return the original source book, or we alter the data to include identifiers.

For now, let's take the first approach. When we get to networking, we can revisit the second approach.

To do this, we only need to alter our two activities.

EditBookActivity

First, let's alter the EditBookActivity.

We use EXTRA_BOOK as the key the incoming source Book. Let's change this to use two keys.

Replace EXTRA_BOOK with:

val EXTRA_SOURCE_BOOK = "${EditBookActivity::class.java}.name::source"
val EXTRA_EDITED_BOOK = "${EditBookActivity::class.java}.name::edited"

Then we'll update the getIntent to handle both parameters. Since we are using this method both coming into the editing as well as returning from it, we'll make the edited book optional.

fun getIntent(context: Context, source: Book, edited: Book? = null): Intent {
    val gson = (context.applicationContext as ReadingListApp).gson
    return Intent(context, EditBookActivity::class.java).apply {
        putExtra(EXTRA_SOURCE_BOOK, gson.toJson(source))
        edited?.let {
            putExtra(EXTRA_EDITED_BOOK, gson.toJson(edited))
        }
    }
}

Cleaning up any of the red error messages, we update our extraBook

private val extraBook: String? by lazy { intent?.getStringExtra(EXTRA_SOURCE_BOOK) }

And finally our onOptionsItemSelected

override fun onOptionsItemSelected(item: MenuItem?): Boolean {
    return when(item?.itemId) {
        R.id.action_save -> {
            binding.book?.let {
                setResult(RESULT_OK, getIntent(
                    context = this,
                    source = it.source,
                    edited = it.edited()
                ))
                finish()
            }
            true
        }
        else -> super.onOptionsItemSelected(item)
    }
}

BookListActivity

Now to clean up the errors in BookListActivity caused by our changes.

private val viewAdapter = ReadingListAdapter(
    bookClicked = {
        startActivityForResult(EditBookActivity.getIntent(
            context = this,
            source = it
        ), EDIT_REQUEST_CODE)
    }
)

Next, onActivityResult will get more complicated, so let's extract some of that functionality to make it easier.

Let's start by moving the logic out that gets the Json from the Intent and converts it to a Book object.

private fun getBook(data: Intent?, key: String): Book? {
    return data?.getStringExtra(key)?.let {
        app.gson.fromJson(it, Book::class.java)
    }
}

That's going to allow us to do

val source = getBook(data, EditBookActivity.EXTRA_SOURCE_BOOK)
val edited = getBook(data, EditBookActivity.EXTRA_EDITED_BOOK)

So we can now tweak our onActivityResult

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    // We ignore any other request we see
    if (requestCode == EDIT_REQUEST_CODE) {
        // We ignore any canceled result
        if (resultCode == Activity.RESULT_OK) {
            // Grab the books from the data
            val source = getBook(data, EditBookActivity.EXTRA_SOURCE_BOOK)
            val edited = getBook(data, EditBookActivity.EXTRA_EDITED_BOOK)
            if (source != null && edited != null) {
                // Grab the old reading list since it is read-only
                viewAdapter.readingList?.let { oldReadingList ->
                    // Create a new ReadingList
                    val newReadingList = ReadingList(
                        title = oldReadingList.title,
                        books = oldReadingList.books
                            .toMutableList()
                            .filterNot {
                                it.title == source.title
                                && it.author == source.author
                                && it.year == source.year
                            }.plus(edited)
                            .toList()
                    )
                    // Save the results
                    saveJson(newReadingList)
                    // And update our view
                    viewAdapter.readingList = newReadingList
                }
            }
        }
    }
    super.onActivityResult(requestCode, resultCode, data)
}

If neither the source or edited are null, then we go ahead and remove the old book and add the new one. Since we do not have some database identifier, we are filtering based off of all the data fields. Not the most efficient, but it will do for now.

Since we have currently corrupted the data, clear your application data before re-deploying your application.

Edit a book, save it, and double check your list.

All looks good, right?

Close the application and re-launch it. Where's your changes?

Take a look at the BookListActivity.loadJson. Do you see the problem?

Reloading changes

Our application reloads the UI when we change data, but when we restart the application, it starts over from the version of the data distributed with the application.

Let's change this to only fallback to the assets, and use our saved version by default.

We'll start by making a copy of loadJson() called loadJsonFromAssets().

private fun loadJsonFromAssets(): ReadingList? {
    return try{
        val reader = InputStreamReader(assets.open(JSON_FILE))
        app.gson.fromJson(reader, ReadingList::class.java)
    } catch (e: Exception) {
        // @TODO introduce logging
        null
    }
}

Then we'll make a loadJsonFromFiles().

private fun loadJsonFromFiles(): ReadingList? {
    return try {
        val reader = FileReader(File(filesDir, JSON_FILE))
        app.gson.fromJson(reader, ReadingList::class.java)
    } catch (e: Exception) {
        // @TODO introduce logging
        null
    }
}

You'll notice that it's almost identical - but we change the reader to reflect our prior saveJson method.

Finally, update our loadJson() function to call both of these.

private fun loadJson(): ReadingList? {
    return loadJsonFromFiles() ?: loadJsonFromAssets()
}

This will call loadJsonFromFiles() and return the results; however, if the results are null, then it will instead return the results of loadJsonFromAssets().

Redeploy your application. Make some changes, exit the app and relaunch it.

Add new entries

To finish off this chapter, let's add the ability to not just edit existing books, but add entirely new ones.

First, we'll need a button to click.

In your module-level build.gradle add a new dependency on the material components

implementation 'com.google.android.material:material:1.1.0-alpha01'

Then, in your activity_book_list.xml, we'll insert a new FloatingActionButton at the end

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

    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/cornsilk"
        tools:context=".BookListActivity">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler"
            android:scrollbars="vertical"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="end|bottom"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            android:src="@android:drawable/ic_input_add"
            android:layout_margin="16dp" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

That will add our FAB icon to the bottom right corner using the colorAccent specified in our styles.

Deploy to see what it looks like.

Now, we need to handle the button click. Let's go ahead and define this one programmatically.

Open BookListActivity.

In your companion object add another request code.

const val NEW_REQUEST_CODE = 1235

This will allow us to differentiate between new book requests vs edit book requests.

In your onCreate, after configuring the recycler let's add a snippet.

binding.fab.setOnClickListener {
    startActivityForResult(EditBookActivity.getIntent(
        context = this
    ), NEW_REQUEST_CODE)
}

Uh oh, there is a red warning. What does it say? No value passed for parameter source.

Ctrl+Clicking on getIntent will take you to that method in EditBookActivity. We can see that the source: Book is currently listed as required. Let's make some changes so that can be optional.

First, add a ? to the Book; then we will need to wrap the putExtra in a check like we did for edited.

fun getIntent(context: Context, source: Book? = null, edited: Book? = null): Intent {
    val gson = (context.applicationContext as ReadingListApp).gson
    return Intent(context, EditBookActivity::class.java).apply {
        source?.let {
            putExtra(EXTRA_SOURCE_BOOK, gson.toJson(source))
        }
        edited?.let {
            putExtra(EXTRA_EDITED_BOOK, gson.toJson(edited))
        }
    }
}

If we follow the trail, we will see that extraBook already handles being null

private val extraBook: String? by lazy { intent?.getStringExtra(EXTRA_SOURCE_BOOK) }

However, inside onCreate does not. We'll need to adjust that.

extraBook?.let {
    binding.book = EditableBook(
        source = app.gson.fromJson(it, Book::class.java)
    )
} ?: let {
    binding.book = EditableBook()
}

There's another one. EditableBook requires the source to be non-null. Let's fix that as well.

Changing the constructor to take a Book? makes red errors appear in the ObservableFields. We need to add null checks there as well.

class EditableBook(val source: Book? = null) {
    val title = ObservableField<String>(source?.title)
    val author = ObservableField<String>(source?.author?.displayName())
    val year = ObservableField<String>(source?.year)

So what's missing?

Go back to BookListActivity. Notice that our onActivityResult will only handle EDIT_REQUEST_CODE.

We could make a new section to handle NEW_REQUEST_CODE, but for now it seems like the code can handle both use cases.

if (requestCode == EDIT_REQUEST_CODE || requestCode == NEW_REQUEST_CODE) {

If we do start having if/else here, we might consider switching to a when clause instead.

There is one more caveat here as well. Notice this line

if (source != null && edited != null) {

With the new changes, our source could be null. We need to allow that use case; which means adding more ?s down below.

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    // We ignore any other request we see
    if (requestCode == EDIT_REQUEST_CODE || requestCode == NEW_REQUEST_CODE) {
        // We ignore any canceled result
        if (resultCode == Activity.RESULT_OK) {
            // Grab the books from the data
            val source = getBook(data, EditBookActivity.EXTRA_SOURCE_BOOK)
            val edited = getBook(data, EditBookActivity.EXTRA_EDITED_BOOK)
            if (edited != null) {
                // Grab the old reading list since it is read-only
                viewAdapter.readingList?.let { oldReadingList ->
                    // Create a new ReadingList
                    val newReadingList = ReadingList(
                        title = oldReadingList.title,
                        books = oldReadingList.books
                            .toMutableList()
                            .filterNot {
                                it.title == source?.title
                                && it.author == source?.author
                                && it.year == source?.year
                            }.plus(edited)
                            .toList()
                    )
                    // Save the results
                    saveJson(newReadingList)
                    // And update our view
                    viewAdapter.readingList = newReadingList
                }
            }
        }
    }
    super.onActivityResult(requestCode, resultCode, data)
}

Deploy it and try clicking the new FAB button.

Make a new book and save it. Verify that it is in the list.

Now, just to be sure, exit the application and restart it. Is the book still there?

FAB

Clone this wiki locally