1. Welcome
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 anAdapter
,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 aRecyclerView
to add items with a different layout. Specifically, how to use a secondViewHolder
to add a header above the items displayed inRecyclerView
.
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
.
2. App overview
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:
3. Concept: Headers in RecyclerView
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.
4. Task: Add a header to your RecyclerView
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.
- 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.
- Open SleepNightAdapter.kt.
- Below the
SleepNightListener
class, at the top level, define asealed
class calledDataItem
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 {
}
- Inside the body of the
DataItem
class, define two classes that represent the different types of data items. The first is aSleepNightItem
, which is a wrapper around aSleepNight
, so it takes a single value calledsleepNight
. To make it part of the sealed class, have it extendDataItem
.
data class SleepNightItem(val sleepNight: SleepNight): DataItem()
- The second class is
Header
, to represent a header. Since a header has no actual data, you can declare it as anobject
. That means there will only ever be one instance ofHeader
. Again, have it extendDataItem
.
object Header: DataItem()
- Inside
DataItem
, at the class level, define anabstract
Long
property namedid
. When the adapter usesDiffUtil
to determine whether and how an item has changed, theDiffItemCallback
needs to know the id of each item. You will see an error, becauseSleepNightItem
andHeader
need to override the abstract propertyid
.
abstract val id: Long
- In
SleepNightItem
, overrideid
to return thenightId
.
override val id = sleepNight.nightId
- In
Header
, overrideid
to returnLong.MIN_VALUE
, which is a very, very small number (literally, -2 to the power of 63). So, this will never conflict with anynightId
in existence.
override val id = Long.MIN_VALUE
- 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
- 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" />
- Extract
"Sleep Results"
into a string resource and call itheader_text
.
<string name="header_text">Sleep Results</string>
- In SleepNightAdapter.kt, inside
SleepNightAdapter
, above theViewHolder
class, create a newTextViewHolder
class. This class inflates the textview.xml layout, and returns aTextViewHolder
instance. Since you've done this before, here is the code, and you'll have to importView
andR
:
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
- In
SleepNightAdapter.kt
, at the top level, below theimport
statements and aboveSleepNightAdapter
, 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
- Inside the
SleepNightAdapter
, create a function to overridegetItemViewType()
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
- In the definition of
SleepNightAdapter
, update the first argument for theListAdapter
fromSleepNight
toDataItem
. - In the definition of
SleepNightAdapter
, change the second generic argument for theListAdapter
fromSleepNightAdapter.ViewHolder
toRecyclerView.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()
- Change the signature of
onCreateViewHolder()
to return aRecyclerView.ViewHolder
.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
- 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()
- Change the parameter type of
onBindViewHolder()
fromViewHolder
toRecyclerView.ViewHolder
.
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
- Add a condition to only assign data to the view holder if the holder is a
ViewHolder
.
when (holder) {
is ViewHolder -> {...}
- Cast the object type returned by
getItem()
toDataItem.SleepNightItem
. Your finishedonBindViewHolder()
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
- Change the methods in
SleepNightDiffCallback
to use your newDataItem
class instead of theSleepNight
. 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
- Inside the
SleepNightAdapter
, belowonCreateViewHolder()
, define a functionaddHeaderAndSubmitList()
as shown below. This function takes a list ofSleepNight
. Instead of usingsubmitList()
, provided by theListAdapter
, to submit your list, you will use this function to add a header and then submit the list.
fun addHeaderAndSubmitList(list: List<SleepNight>?) {}
- Inside
addHeaderAndSubmitList()
, if the passed in list isnull
, 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)
- Open SleepTrackerFragment.kt and change the call to
submitList()
toaddHeaderAndSubmitList()
. - Run your app and observe how your header is displayed as the first item in the list of sleep items.
5. Task: Use coroutines for list manipulations
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:
- At the top level inside the
SleepNightAdapter
class, define aCoroutineScope
withDispatchers.Default
.
private val adapterScope = CoroutineScope(Dispatchers.Default)
- In
addHeaderAndSubmitList()
, launch a coroutine in theadapterScope
to manipulate the list. Then switch to theDispatchers.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)
}
}
}
- Your code should build and run, and you won't see any difference.
6. Task: Extend the header to span across the screen
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.
- Open SleepTrackerFragment.kt.
- Find the code where you define
manager
, towards the end ofonCreateView()
.
val manager = GridLayoutManager(activity, 3)
- Below
manager
, definemanager.spanSizeLookup
, as shown. You need to make anobject
becausesetSpanSizeLookup
doesn't take a lambda. To make anobject
in Kotlin, typeobject : classname
, in this caseGridLayoutManager.SpanSizeLookup
.
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
}
- You might get a compiler error to call the constructor. If you do, open the intention menu with
Option+Enter
(Mac) orAlt+Enter
(Windows) to apply the constructor call. - Then you'll get an error on
object
saying you need to override methods. Put the cursor onobject
, pressOption+Enter
(Mac) orAlt+Enter
(Windows) to open the intentions menu, then override the methodgetSpanSize()
. - 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
}
}
- 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"
- Run your app. It should look like the screenshot below.
Congratulations! You are done.
7. Solution code
Android Studio project: RecyclerViewHeaders
8. Summary
- 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. TheAdapter
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 theDataItem
class. - Create a
addHeaderAndSubmitList()
function that uses coroutines to add the header to the dataset and then callssubmitList()
. - Implement
GridLayoutManager.SpanSizeLookup()
to make only the header three spans wide.
9. Learn more
Udacity course:
Android developer documentation:
10. Homework
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
.
11. Next codelab
For links to other codelabs in this course, see the Android Kotlin Fundamentals codelabs landing page.