Android Kotlin Fundamentals 07.5: Headers in RecyclerView

This codelab is part of the Android Kotlin Fundamentals course. You'll get the most value out of this course if you work through the codelabs in sequence. All the course codelabs are listed on the Android Kotlin Fundamentals codelabs landing page.

Introduction

In this codelab, you learn how to add a header that spans the width of the list displayed in a RecyclerView. You build on the sleep-tracker app from previous codelabs.

What you should already know

  • How to build a basic user interface using an activity, fragments, and views.
  • How to navigate between fragments, and how to use safeArgs to pass data between fragments.
  • View models, view model factories, transformations, and LiveData and their observers.
  • How to create a Room database, create a DAO, and define entities.
  • How to use coroutines for database interactions and other long-running tasks.
  • How to implement a basic RecyclerView with an Adapter, ViewHolder, and item layout.
  • How to implement data binding for RecyclerView.
  • How to create and use binding adapters to transform data.
  • How to use GridLayoutManager.
  • How capture and handle clicks on items in a RecyclerView.

What you'll learn

  • How to use more than one ViewHolder with a RecyclerView to add items with a different layout. Specifically, how to use a second ViewHolder to add a header above the items displayed in RecyclerView.

What you'll do

  • Build on the TrackMySleepQuality app from the previous codelab in this series.
  • Add a header that spans the width of the screen above the sleep nights displayed in the RecyclerView.

The sleep-tracker app you start with has three screens, represented by fragments, as shown in the figure below.

The first screen, shown on the left, has buttons for starting and stopping tracking. The screen shows some of the user's sleep data. The Clear button permanently deletes all the data that the app has collected for the user. The second screen, shown in the middle, is for selecting a sleep-quality rating. The third screen is a detail view that opens when the user taps an item in the grid.

This app uses a simplified architecture with a UI controller, view model and LiveData, and a Room database to persist sleep data.

In this codelab, you add a header to the grid of items displayed. Your final main screen will look like this:

This codelab teaches the general principle of including items that use different layouts in a RecyclerView. One common example is having headers in your list or grid. A list can have a single header to describe the item content. A list can also have multiple headers to group and separate items in a single list.

RecyclerView doesn't know anything about your data or what type of layout each item has. The LayoutManager arranges the items on the screen, but the adapter adapts the data to be displayed and passes view holders to the RecyclerView. So you will add the code to create headers in the adapter.

Two ways of adding headers

In RecyclerView, every item in the list corresponds to an index number starting from 0. For example:

[Actual Data] -> [Adapter Views]

[0: SleepNight] -> [0: SleepNight]

[1: SleepNight] -> [1: SleepNight]

[2: SleepNight] -> [2: SleepNight]

One way to add headers to a list is to modify your adapter to use a different ViewHolder by checking indexes where your header needs to be shown. The Adapter will be responsible for keeping track of the header. For example, to show a header at the top of the table, you need to return a different ViewHolder for the header while laying out the zero-indexed item. Then all the other items would be mapped with the header offset, as shown below.

[Actual Data] -> [Adapter Views]

[0: Header]

[0: SleepNight] -> [1: SleepNight]

[1: SleepNight] -> [2: SleepNight]

[2: SleepNight] -> [3: SleepNight.

Another way to add headers is to modify the backing dataset for your data grid. Since all the data that needs to be displayed is stored in a list, you can modify the list to include items to represent a header. This is a bit simpler to understand, but it requires you to think about how you design your objects, so you can combine the different item types into a single list. Implemented this way, the adapter will display the items passed to it. So the item at position 0 is a header, and the item at position 1 is a SleepNight, which maps directly to what's on the screen.

[Actual Data] -> [Adapter Views]

[0: Header] -> [0: Header]

[1: SleepNight] -> [1: SleepNight]

[2: SleepNight] -> [2: SleepNight]

[3: SleepNight] -> [3: SleepNight]

Each methodology has benefits and drawbacks. Changing the dataset doesn't introduce much change to the rest of the adapter code, and you can add header logic by manipulating the list of data. On the other hand, using a different ViewHolder by checking indexes for headers gives more freedom on the layout of the header. It also lets the adapter handle how data is adapted to the view without modifying the backing data.

In this codelab, you update your RecyclerView to display a header at the start of the list. In this case, your app will use a different ViewHolder for the header than for data items. The app will check the index of the list to determine which ViewHolder to use.

Step 1: Create a DataItem class

To abstract the type of item and let the adapter just deal with "items", you can create a data holder class that represents either a SleepNight or a Header. Your dataset will then be a list of data holder items.

You can either get the starter app from GitHub, or continue using the SleepTracker app you built in the previous codelab.

  1. Download the RecyclerViewHeaders-Starter code from GitHub. The RecyclerViewHeaders-Starter directory contains the starter version of the SleepTracker app needed for this codelab. You can also continue with your finished app from the previous codelab if you prefer.
  2. Open SleepNightAdapter.kt.
  3. Below the SleepNightListener class, at the top level, define a sealed class called DataItem that represents an item of data.

    A sealed class defines a closed type, which means that all subclasses of DataItem must be defined in this file. As a result, the number of subclasses is known to the compiler. It's not possible for another part of your code to define a new type of DataItem that could break your adapter.
sealed class DataItem {

 }
  1. Inside the body of the DataItem class, define two classes that represent the different types of data items. The first is a SleepNightItem, which is a wrapper around a SleepNight, so it takes a single value called sleepNight. To make it part of the sealed class, have it extend DataItem.
data class SleepNightItem(val sleepNight: SleepNight): DataItem()
  1. The second class is Header, to represent a header. Since a header has no actual data, you can declare it as an object. That means there will only ever be one instance of Header. Again, have it extend DataItem.
object Header: DataItem()
  1. Inside DataItem, at the class level, define an abstract Long property named id. When the adapter uses DiffUtil to determine whether and how an item has changed, the DiffItemCallback needs to know the id of each item. You will see an error, because SleepNightItem and Header need to override the abstract property id.
abstract val id: Long
  1. In SleepNightItem, override id to return the nightId.
override val id = sleepNight.nightId
  1. In Header, override id to return Long.MIN_VALUE, which is a very, very small number (literally, -2 to the power of 63). So, this will never conflict with any nightId in existence.
override val id = Long.MIN_VALUE
  1. Your finished code should look like this, and your app should build without errors.
sealed class DataItem {
    abstract val id: Long
    data class SleepNightItem(val sleepNight: SleepNight): DataItem()      {
        override val id = sleepNight.nightId
    }

    object Header: DataItem() {
        override val id = Long.MIN_VALUE
    }
}

Step 2: Create a ViewHolder for the Header

  1. Create the layout for the header in a new layout resource file called header.xml that displays a TextView. There is nothing exciting about this, so here is the code.
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textAppearance="?android:attr/textAppearanceLarge"
    android:text="Sleep Results"
    android:padding="8dp" />
  1. Extract "Sleep Results" into a string resource and call it header_text.
<string name="header_text">Sleep Results</string>
  1. In SleepNightAdapter.kt, inside SleepNightAdapter, above the ViewHolder class, create a new TextViewHolder class. This class inflates the textview.xml layout, and returns a TextViewHolder instance. Since you've done this before, here is the code, and you'll have to import View and R:
    class TextViewHolder(view: View): RecyclerView.ViewHolder(view) {
        companion object {
            fun from(parent: ViewGroup): TextViewHolder {
                val layoutInflater = LayoutInflater.from(parent.context)
                val view = layoutInflater.inflate(R.layout.header, parent, false)
                return TextViewHolder(view)
            }
        }
    }

Step 3: Update SleepNightAdapter

Next you need to update the declaration of SleepNightAdapter. Instead of only supporting one type of ViewHolder, it needs to be able to use any type of view holder.

Define the types of items

  1. In SleepNightAdapter.kt, at the top level, below the import statements and above SleepNightAdapter, define two constants for the view types.

    The RecyclerView will need to distinguish each item's view type, so that it can correctly assign a view holder to it.
    private val ITEM_VIEW_TYPE_HEADER = 0
    private val ITEM_VIEW_TYPE_ITEM = 1
  1. Inside the SleepNightAdapter, create a function to override getItemViewType() to return the right header or item constant depending on the type of the current item.
override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is DataItem.Header -> ITEM_VIEW_TYPE_HEADER
            is DataItem.SleepNightItem -> ITEM_VIEW_TYPE_ITEM
        }
    }

Update the SleepNightAdapter definition

  1. In the definition of SleepNightAdapter, update the first argument for the ListAdapter from SleepNight to DataItem.
  2. In the definition of SleepNightAdapter, change the second generic argument for the ListAdapter from SleepNightAdapter.ViewHolder to RecyclerView.ViewHolder. You will see some errors for necessary updates, and your class header should look like shown below.
class SleepNightAdapter(val clickListener: SleepNightListener):
       ListAdapter<DataItem, RecyclerView.ViewHolder>(SleepNightDiffCallback()) {

Update onCreateViewHolder()

  1. Change the signature of onCreateViewHolder() to return a RecyclerView.ViewHolder.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
  1. Expand the implementation of the onCreateViewHolder() method to test for and return the appropriate view holder for each item type. Your updated method should look like the code below.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            ITEM_VIEW_TYPE_HEADER -> TextViewHolder.from(parent)
            ITEM_VIEW_TYPE_ITEM -> ViewHolder.from(parent)
            else -> throw ClassCastException("Unknown viewType ${viewType}")
        }
    }

Update onBindViewHolder()

  1. Change the parameter type of onBindViewHolder() from ViewHolder to RecyclerView.ViewHolder.
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
  1. Add a condition to only assign data to the view holder if the holder is a ViewHolder.
        when (holder) {
            is ViewHolder -> {...}
  1. Cast the object type returned by getItem() to DataItem.SleepNightItem. Your finished onBindViewHolder() function should look like this.
  override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder) {
            is ViewHolder -> {
                val nightItem = getItem(position) as DataItem.SleepNightItem
                holder.bind(nightItem.sleepNight, clickListener)
            }
        }
    }

Update the diffUtil callbacks

  1. Change the methods in SleepNightDiffCallback to use your new DataItem class instead of the SleepNight. Suppress the lint warning as shown in the code below.
class SleepNightDiffCallback : DiffUtil.ItemCallback<DataItem>() {
    override fun areItemsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
        return oldItem.id == newItem.id
    }
    @SuppressLint("DiffUtilEquals")
    override fun areContentsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
        return oldItem == newItem
    }
}

Add and submit the header

  1. Inside the SleepNightAdapter, below onCreateViewHolder(), define a function addHeaderAndSubmitList() as shown below. This function takes a list of SleepNight. Instead of using submitList(), provided by the ListAdapter, to submit your list, you will use this function to add a header and then submit the list.
fun addHeaderAndSubmitList(list: List<SleepNight>?) {}
  1. Inside addHeaderAndSubmitList(), if the passed in list is null, return just a header, otherwise, attach the header to the head of the list, and then submit the list.
val items = when (list) {
                null -> listOf(DataItem.Header)
                else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
            }
submitList(items)
  1. Open SleepTrackerFragment.kt and change the call to submitList() to addHeaderAndSubmitList().
  1. Run your app and observe how your header is displayed as the first item in the list of sleep items.

There are two things that need to be fixed for this app. One is visible, and one is not.

  • The header shows up in the top-left corner, and is not easily distinguishable.
  • It doesn't matter much for a short list with one header, but you should not do list manipulation in addHeaderAndSubmitList() on the UI thread. Imagine a list with hundreds of items, multiple headers, and logic to decide where items need to be inserted. This work belongs in a coroutine.

Change addHeaderAndSubmitList() to use coroutines:

  1. At the top level inside the SleepNightAdapter class, define a CoroutineScope with Dispatchers.Default.
private val adapterScope = CoroutineScope(Dispatchers.Default)
  1. In addHeaderAndSubmitList(), launch a coroutine in the adapterScope to manipulate the list. Then switch to the Dispatchers.Main context to submit the list, as shown in the code below.
 fun addHeaderAndSubmitList(list: List<SleepNight>?) {
        adapterScope.launch {
            val items = when (list) {
                null -> listOf(DataItem.Header)
                else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
            }
            withContext(Dispatchers.Main) {
                submitList(items)
            }
        }
    }
  1. Your code should build and run, and you won't see any difference.

Currently, the header is the same width as the other items on the grid, taking up one span horizontally and vertically. The whole grid fits three items of one span width horizontally, so the header should use three spans horizontally.

To fix the header width, you need to tell the GridLayoutManager when to span the data across all the columns. You can do this by configuring the SpanSizeLookup on a GridLayoutManager. This is a configuration object that the GridLayoutManager uses to determine how many spans to use for each item in the list.

  1. Open SleepTrackerFragment.kt.
  2. Find the code where you define manager, towards the end of onCreateView().
val manager = GridLayoutManager(activity, 3)
  1. Below manager, define manager.spanSizeLookup, as shown. You need to make an object because setSpanSizeLookup doesn't take a lambda. To make an object in Kotlin, type object : classname, in this case GridLayoutManager.SpanSizeLookup.
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
}
  1. You might get a compiler error to call the constructor. If you do, open the intention menu with Option+Enter (Mac) or Alt+Enter (Windows) to apply the constructor call.
  1. Then you'll get an error on object saying you need to override methods. Put the cursor on object, press Option+Enter (Mac) or Alt+Enter (Windows) to open the intentions menu, then override the method getSpanSize().
  1. In the body of getSpanSize(), return the right span size for each position. Position 0 has a span size of 3, and the other positions have a span size of 1. Your completed code should look like the code below:
    manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
            override fun getSpanSize(position: Int) =  when (position) {
                0 -> 3
                else -> 1
            }
        }
  1. To improve how your header looks, open header.xml and add this code to the layout file header.xml.
android:textColor="@color/white_text_color"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:background="@color/colorAccent"
  1. Run your app. It should look like the screenshot below.

Congratulations! You are done.

Android Studio project: RecyclerViewHeaders

  • A header is generally an item that spans the width of a list and acts as a title or separator. A list can have a single header to describe the item content, or multiple headers to group items and separate items from each other.
  • A RecyclerView can use multiple view holders to accommodate a heterogeneous set of items; for example, headers and list items.
  • One way to add headers is to modify your adapter to use a different ViewHolder by checking indexes where your header needs to be shown. The Adapter is responsible for keeping track of the header.
  • Another way to add headers is to modify the backing dataset (the list) for your data grid, which is what you did in this codelab.

These are the major steps for adding a header:

  • Abstract the data in your list by creating a DataItem that can hold a header or data.
  • Create a view holder with a layout for the header in the adapter.
  • Update the adapter and its methods to use any kind of RecyclerView.ViewHolder.
  • In onCreateViewHolder(), return the correct type of view holder for the data item.
  • Update SleepNightDiffCallback to work with the DataItem class.
  • Create a addHeaderAndSubmitList() function that uses coroutines to add the header to the dataset and then calls submitList().
  • Implement GridLayoutManager.SpanSizeLookup() to make only the header three spans wide.

Udacity course:

Android developer documentation:

This section lists possible homework assignments for students who are working through this codelab as part of a course led by an instructor. It's up to the instructor to do the following:

  • Assign homework if required.
  • Communicate to students how to submit homework assignments.
  • Grade the homework assignments.

Instructors can use these suggestions as little or as much as they want, and should feel free to assign any other homework they feel is appropriate.

If you're working through this codelab on your own, feel free to use these homework assignments to test your knowledge.

Answer these questions

Question 1

Which of the following statements is true about ViewHolder?

▢ An adapter can use multiple ViewHolder classes to hold headers and various types of data.

▢ You can have exactly one view holder for data, and one view holder for a header.

▢ A RecyclerView supports multiple types of headers, but the data has to be uniform.

▢ When adding a header, you subclass RecyclerView to insert the header at the correct position.

Question 2

When should you use coroutines with a RecyclerView? Select all the statements that are true.

▢ Never. A RecyclerView is a UI element and should not use coroutines.

▢ Use coroutines for long-running tasks that could slow the UI.

▢ List manipulations can take a long time, and you should always do them using coroutines.

▢ Use coroutines with suspend functions to avoid blocking the main thread.

Question 3

Which of the following do you NOT have to do when using more than one ViewHolder?

▢ In the ViewHolder, provide multiple layout files to inflate as needed.

▢ In onCreateViewHolder(), return the correct type of view holder for the data item.

▢ In onBindViewHolder(), only bind data if the view holder is the correct type of view holder for the data item.

▢ Generalize the adapter class signature to accept any RecyclerView.ViewHolder.

Start the next lesson: 8.1 Getting data from the internet

For links to other codelabs in this course, see the Android Kotlin Fundamentals codelabs landing page.