Android Kotlin 基礎知識 07.5:RecyclerView 中的標頭

這個程式碼研究室是 Android Kotlin 基礎知識課程的一部分。如果您按部就班完成程式碼研究室,就能充分體驗到本課程的價值。所有課程程式碼研究室都列在 Android Kotlin 基礎知識程式碼研究室到達網頁

簡介

在本程式碼研究室中,您將瞭解如何新增標題,使其跨越 RecyclerView 中顯示的清單寬度。您將以先前程式碼研究室的睡眠追蹤器應用程式為基礎。

必備知識

  • 如何使用活動、片段和檢視畫面建構基本使用者介面。
  • 如何瀏覽各個片段之間,以及如何使用 safeArgs 在片段之間傳遞資料。
  • 查看模型、模型工廠、轉換和 LiveData 及其觀察者。
  • 如何建立 Room 資料庫、建立 DAO,以及定義實體。
  • 如何使用協同程式進行資料庫互動,以及處理其他長時間執行的工作。
  • 如何使用 AdapterViewHolder 和項目版面配置實作基本 RecyclerView
  • 如何為 RecyclerView 實作資料繫結。
  • 如何建立及使用繫結轉接程式來轉換資料。
  • 如何使用 GridLayoutManager
  • 如何擷取及處理 RecyclerView. 中項目的點擊事件

課程內容

  • 如何搭配 RecyclerView 使用多個 ViewHolder,新增不同版面配置的項目。具體來說,就是如何使用第二個 ViewHolder,在 RecyclerView 中顯示的項目上方新增標題。

學習內容

  • 以本系列先前程式碼研究室的 TrackMySleepQuality 應用程式為基礎,
  • RecyclerView 中顯示的睡眠夜數上方,新增橫跨整個螢幕寬度的標題。

您一開始使用的睡眠追蹤器應用程式有三個畫面,以片段表示,如下圖所示。

左側顯示的第一個畫面有開始和停止追蹤的按鈕。畫面上會顯示部分使用者的睡眠資料。「清除」按鈕會永久刪除應用程式為使用者收集的所有資料。中間的第二個畫面用於選取睡眠品質評分。第三個畫面是詳細資料檢視畫面,使用者輕觸格線中的項目時就會開啟。

這個應用程式採用簡化架構,包含 UI 控制器、檢視模型和 LiveData,以及用於保存睡眠資料的 Room 資料庫。

在本程式碼研究室中,您會在顯示的項目格線中新增標題。最終的主畫面如下所示:

本程式碼研究室將說明在 RecyclerView 中加入使用不同版面配置項目的基本原則。常見的例子是在清單或格線中加入標題。清單可以有一個標題,用來描述項目內容。清單也可以有多個標題,用於分組及分隔單一清單中的項目。

RecyclerView 不會知道您的資料,也不會知道每個項目的版面配置類型。LayoutManager 會在畫面上排列項目,但轉接器會調整要顯示的資料,並將檢視區塊控制代碼傳遞至 RecyclerView。因此您會在轉接器中新增程式碼來建立標頭。

新增標題的兩種方式

RecyclerView 中,清單中的每個項目都對應到從 0 開始的索引編號。例如:

[實際資料] -> [介面卡檢視畫面]

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

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

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

如要將標頭加入清單,其中一種方法是修改介面卡,透過檢查標頭顯示位置的索引,使用不同的 ViewHolderAdapter 會負責追蹤標頭。舉例來說,如要在表格頂端顯示標題,您需要在版面配置以零為索引的項目時,傳回標題的 ViewHolder。然後,所有其他項目都會對應至標題偏移,如下所示。

[實際資料] -> [介面卡檢視畫面]

[0: Header]

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

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

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

如要新增標題,也可以修改資料格的支援資料集。由於需要顯示的所有資料都儲存在清單中,因此您可以修改清單,加入代表標題的項目。這種做法較容易理解,但您必須思考如何設計物件,才能將不同項目類型合併為單一清單。以這種方式實作後,轉接器就會顯示傳遞給它的項目。因此位置 0 的項目是標題,位置 1 的項目是 SleepNight,直接對應到畫面上的內容。

[實際資料] -> [介面卡檢視畫面]

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

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

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

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

每種方法都有優缺點。變更資料集不會對其餘的轉接器程式碼造成太大影響,而且您可以透過操控資料清單來新增標頭邏輯。另一方面,透過檢查標頭的索引使用不同的 ViewHolder,可讓您更自由地設定標頭版面配置。此外,轉接器也能處理資料如何調整至檢視畫面,而不需修改支援資料。

在本程式碼研究室中,您會更新 RecyclerView,在清單開頭顯示標題。在這種情況下,應用程式會使用不同的 ViewHolder 做為標頭和資料項目的標頭。應用程式會檢查清單的索引,判斷要使用哪個 ViewHolder

步驟 1:建立 DataItem 類別

如要將項目類型抽象化,讓轉接器只處理「項目」,您可以建立資料持有者類別,代表 SleepNightHeader。資料集隨後會成為資料持有者項目清單。

您可以從 GitHub 取得範例應用程式,也可以繼續使用您在先前的程式碼研究室中建構的 SleepTracker 應用程式。

  1. 從 GitHub 下載 RecyclerViewHeaders-Starter 程式碼。RecyclerViewHeaders-Starter 目錄包含本程式碼研究室所需的 SleepTracker 應用程式入門版本。您也可以選擇繼續使用先前程式碼研究室中完成的應用程式。
  2. 開啟「SleepNightAdapter.kt」SleepNightAdapter.kt
  3. SleepNightListener 類別下方,於頂層定義名為 DataItemsealed 類別,代表資料項目。

    sealed 類別會定義封閉型別,也就是說,DataItem 的所有子類別都必須在這個檔案中定義。因此,編譯器會知道子類別的數量。程式碼的其他部分無法定義可能導致配接器中斷的新型 DataItem
sealed class DataItem {

 }
  1. DataItem 類別的主體中,定義兩個類別,分別代表不同類型的資料項目。第一個是 SleepNightItem,這是 SleepNight 的包裝函式,因此會採用名為 sleepNight 的單一值。如要將其設為密封類別的一部分,請擴充 DataItem
data class SleepNightItem(val sleepNight: SleepNight): DataItem()
  1. 第二個類別是 Header,代表標題。由於標頭沒有實際資料,因此可以宣告為 object。也就是說,只會有一個 Header 執行個體。再次將其擴充為 DataItem
object Header: DataItem()
  1. DataItem 內,於類別層級定義名為 idabstract Long 屬性。當配接器使用 DiffUtil 判斷項目是否及如何變更時,DiffItemCallback 需要知道每個項目的 ID。您會看到錯誤訊息,因為 SleepNightItemHeader 需要覆寫抽象屬性 id
abstract val id: Long
  1. SleepNightItem 中,覆寫 id 以傳回 nightId
override val id = sleepNight.nightId
  1. Header 中,覆寫 id 以傳回 Long.MIN_VALUE,這是非常小的數字 (實際上是 -2 的 63 次方)。因此,這項設定絕不會與現有的任何 nightId 發生衝突。
override val id = Long.MIN_VALUE
  1. 完成的程式碼應如下所示,且應用程式應能順利建構。
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
    }
}

步驟 2:為標題建立 ViewHolder

  1. 在名為 header.xml 的新版面配置資源檔案中,建立標題的版面配置,顯示 TextView。這部分沒有什麼特別之處,因此我們直接提供程式碼。
<?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. "Sleep Results" 擷取為字串資源,並命名為 header_text
<string name="header_text">Sleep Results</string>
  1. SleepNightAdapter.ktSleepNightAdapter 內,於 ViewHolder 類別上方建立新的 TextViewHolder 類別。這個類別會膨脹 textview.xml 版面配置,並傳回 TextViewHolder 例項。由於您先前已完成這項操作,因此這裡提供程式碼,您必須匯入 ViewR
    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)
            }
        }
    }

步驟 3:更新 SleepNightAdapter

接著,您需要更新 SleepNightAdapter 的聲明。它必須能夠使用任何類型的檢視畫面持有者,而不僅支援一種 ViewHolder

定義項目類型

  1. SleepNightAdapter.kt 的頂層,於 import 陳述式下方和 SleepNightAdapter 上方,定義兩個檢視區塊類型的常數。

    RecyclerView 必須區分每個項目的檢視類型,才能正確指派檢視容器。
    private val ITEM_VIEW_TYPE_HEADER = 0
    private val ITEM_VIEW_TYPE_ITEM = 1
  1. SleepNightAdapter 內,建立函式來覆寫 getItemViewType(),根據目前項目的類型傳回正確的標題或項目常數。
override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is DataItem.Header -> ITEM_VIEW_TYPE_HEADER
            is DataItem.SleepNightItem -> ITEM_VIEW_TYPE_ITEM
        }
    }

更新 SleepNightAdapter 定義

  1. SleepNightAdapter 的定義中,將 ListAdapter 的第一個引數從 SleepNight 更新為 DataItem
  2. SleepNightAdapter 的定義中,將 ListAdapter 的第二個泛型引數從 SleepNightAdapter.ViewHolder 變更為 RecyclerView.ViewHolder。您會看到必要更新的一些錯誤,且類別標頭應如下所示。
class SleepNightAdapter(val clickListener: SleepNightListener):
       ListAdapter<DataItem, RecyclerView.ViewHolder>(SleepNightDiffCallback()) {

更新 onCreateViewHolder()

  1. 變更 onCreateViewHolder() 的簽名,以傳回 RecyclerView.ViewHolder
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
  1. 擴展 onCreateViewHolder() 方法的實作方式,針對每個項目類型測試並傳回適當的檢視區塊持有者。更新後的方法應如下所示。
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}")
        }
    }

更新 onBindViewHolder()

  1. onBindViewHolder() 的參數類型從 ViewHolder 變更為 RecyclerView.ViewHolder
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)
  1. 新增條件,只在檢視區塊持有者為 ViewHolder 時,才將資料指派給該持有者。
        when (holder) {
            is ViewHolder -> {...}
  1. getItem() 傳回的物件類型轉換為 DataItem.SleepNightItem。完成的 onBindViewHolder() 函式應如下所示。
  override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder) {
            is ViewHolder -> {
                val nightItem = getItem(position) as DataItem.SleepNightItem
                holder.bind(nightItem.sleepNight, clickListener)
            }
        }
    }

更新 DiffUtil 回呼

  1. 變更 SleepNightDiffCallback 中的方法,改用新的 DataItem 類別,而非 SleepNight。如以下程式碼所示,禁止顯示 Lint 警告。
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
    }
}

新增並提交標頭

  1. SleepNightAdapter 內,於 onCreateViewHolder() 下方定義函式 addHeaderAndSubmitList(),如下所示。這個函式會接受 SleepNight 清單。您不會使用 ListAdapter 提供的 submitList() 提交清單,而是使用這個函式新增標頭,然後提交清單。
fun addHeaderAndSubmitList(list: List<SleepNight>?) {}
  1. addHeaderAndSubmitList() 內,如果傳入的清單是 null,則只會傳回標頭;否則,請將標頭附加至清單開頭,然後提交清單。
val items = when (list) {
                null -> listOf(DataItem.Header)
                else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
            }
submitList(items)
  1. 開啟 SleepTrackerFragment.kt,並將對 submitList() 的呼叫變更為 addHeaderAndSubmitList()
  1. 執行應用程式,並觀察標題在睡眠項目清單中顯示為第一個項目。

這個應用程式有兩項問題需要修正,其中一項是可見的,另一項則否。

  • 標題會顯示在左上角,且不易辨識。
  • 如果清單很短且只有一個標題,這點就沒什麼關係,但您不應在 addHeaderAndSubmitList() 的 UI 執行緒中操作清單。假設清單有數百個項目、多個標題,以及決定項目插入位置的邏輯。這項工作屬於協同程式。

addHeaderAndSubmitList() 變更為使用協同程式:

  1. SleepNightAdapter 類別的頂層,定義具有 Dispatchers.DefaultCoroutineScope
private val adapterScope = CoroutineScope(Dispatchers.Default)
  1. addHeaderAndSubmitList() 中,於 adapterScope 啟動協同程式,以操控清單。然後切換至 Dispatchers.Main 內容,提交清單,如下列程式碼所示。
 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. 程式碼應會建構及執行,您不會看到任何差異。

目前,標題的寬度與格線上的其他項目相同,水平和垂直方向都佔用一個跨距。整個格線在水平方向上可容納三個跨度寬度的項目,因此標題應在水平方向上使用三個跨度。

如要修正標題寬度,您必須告知 GridLayoutManager 何時要將資料跨越所有資料欄。方法是在 GridLayoutManager 上設定 SpanSizeLookup。這是設定物件,GridLayoutManager 會使用這個物件,判斷清單中每個項目要使用的跨度數量。

  1. 開啟 SleepTrackerFragment.kt
  2. onCreateView() 結尾附近,找出定義 manager 的程式碼。
val manager = GridLayoutManager(activity, 3)
  1. manager 下方定義 manager.spanSizeLookup,如下所示。您需要建立 object,因為 setSpanSizeLookup 不會採用 lambda。如要在 Kotlin 中建立 object,請輸入 object : classname,在本例中為 GridLayoutManager.SpanSizeLookup
manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
}
  1. 您可能會收到編譯器錯誤,要求呼叫建構函式。如果有的話,請使用 Option+Enter (Mac) 或 Alt+Enter (Windows) 開啟意圖選單,套用建構函式呼叫。
  1. 接著,您會在 object 上收到錯誤訊息,指出您需要覆寫方法。將游標放在 object 上,按下 Option+Enter (Mac) 或 Alt+Enter (Windows) 開啟意圖選單,然後覆寫 getSpanSize() 方法。
  1. getSpanSize() 的主體中,傳回每個位置的正確跨度大小。位置 0 的跨度大小為 3,其他位置的跨度大小為 1。完成的程式碼應如下所示:
    manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
            override fun getSpanSize(position: Int) =  when (position) {
                0 -> 3
                else -> 1
            }
        }
  1. 如要改善標題的外觀,請開啟 header.xml,然後將這段程式碼新增至版面配置檔案 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. 執行應用程式,畫面應如下方螢幕截圖所示。

恭喜!大功告成。

Android Studio 專案:RecyclerViewHeaders

  • 標頭通常是跨越清單寬度的項目,可做為標題或分隔符。清單可以有一個標題來描述項目內容,也可以有多個標題來分組項目,並將項目彼此分開。
  • RecyclerView 可使用多個檢視畫面持有者,以容納異質項目集,例如標頭和清單項目。
  • 如要新增標頭,其中一種方法是修改轉接程式,檢查標頭需要顯示的索引,藉此使用不同的 ViewHolderAdapter 負責追蹤標頭。
  • 新增標題的另一種方式是修改資料格的支援資料集 (清單),這也是您在本程式碼研究室中執行的操作。

新增頁首的主要步驟如下:

  • 建立可保留標題或資料的 DataItem,即可將清單中的資料抽象化。
  • 在轉接器中,使用標題的版面配置建立檢視畫面容器。
  • 更新轉接器及其方法,以便使用任何類型的 RecyclerView.ViewHolder
  • onCreateViewHolder() 中,傳回資料項目的正確檢視區塊控制代碼類型。
  • 更新 SleepNightDiffCallback 以使用 DataItem 類別。
  • 建立 addHeaderAndSubmitList() 函式,使用協同程式將標頭新增至資料集,然後呼叫 submitList()
  • 實作 GridLayoutManager.SpanSizeLookup(),讓標題只佔三個跨度。

Udacity 課程:

Android 開發人員說明文件:

本節列出的作業可由課程講師指派給學習本程式碼研究室的學員。講師可自由採取以下行動:

  • 視需要指派作業。
  • 告知學員如何繳交作業。
  • 為作業評分。

講師可以視需求使用全部或部分建議內容,也可以自由指派任何其他合適的作業。

如果您是自行學習本程式碼研究室,不妨利用這些作業驗收學習成果。

回答問題

第 1 題

以下有關 ViewHolder 的敘述何者正確?

▢ 轉接程式可使用多個 ViewHolder 類別來保存標頭及不同類型的資料。

▢ 您可以將資料和標頭分別置於兩個 ViewHolder 中。

RecyclerView 可支援多種類型的標頭,但資料必須一致。

▢ 新增標頭時,可將 RecyclerView 設為子類別,將標頭插入至正確的位置。

第 2 題

何時應將協同程式與 RecyclerView 搭配使用?選取所有正確的敘述。

▢ 永不。RecyclerView 是 UI 元素,不應使用協同程式。

▢ 對於可能導致 UI 變慢的長時間執行工作,請使用協同程式。

▢ 清單操作可能需要很長時間,因此您應一律使用協同程式執行。

▢ 使用協同程式和暫停函式,避免阻斷主執行緒。

第 3 題

使用多個 ViewHolder 時,下列哪項「不」是必要步驟?

▢ 在 ViewHolder 中,視需要提供多個要膨脹的版面配置檔案。

▢ 在 onCreateViewHolder() 中,傳回資料項目的正確檢視區塊控制代碼類型。

▢ 在 onBindViewHolder() 中,只有在 View Holder 是資料項目的正確 View Holder 類型時,才繫結資料。

▢ 泛化轉接器類別簽名,以接受任何 RecyclerView.ViewHolder

開始下一個課程:8.1 從網際網路取得資料

如要查看本課程其他程式碼研究室的連結,請參閱 Android Kotlin 基礎知識程式碼研究室登陸頁面