Tina Tang's Blog

在哪裡跌倒了,就在哪裡躺下來

0%

Android筆記(24)-Fragments和Navigation Component

許多 Android 應用程式不需個別為每個畫面設定活動。事實上,許多常見的使用者介面模式 (如分頁標籤(tabs)) 均存在單一 activity 內,並使用名為「fragment」的組成部分。

fragment是可重複使用的使用者介面,並可嵌入一或多個 activity 中。在上方的螢幕截圖中,輕觸 tabs 並不會觸發顯示下一個畫面的 intent。而是,切換 tabs 僅僅是在先前 fragment 與原先 fragment 之間調換。這些事項都不需要啟動其他 activity。

您甚至可以在單一畫面上一次顯示多個 fragment,例如平板電腦裝置的主控制項詳細資料 layout。在以下範例中,左邊的導覽使用者介面和右側的內容都可以包含在不同的 fragment 中。兩個 fragment 在同一個 activity 中並存。

如您所見,fragment 是建構高品質應用程式的關鍵要素。在本程式碼研究室中,您將瞭解 fragment 的基本概念,並轉換 Word 應用程式來使用 fragment。也會瞭解如何使用 Jetpack Navigation component,以及使用名為 Navigation Graph 的新資源檔案,以在同一主機活動中導覽不同 fragment。完成本程式碼研究室後,您將獲得在下一個應用程式中導入 fragment 的基本技能。

學習目標

  • fragment 生命週期 activity 生命週期的差異。
  • 如何將現有 activity 轉換成 fragment。
  • 如何在 Navigation Graph 中新增目的地,以及在使用 Safe Args 外掛程式時在 fragment 之間傳遞資料。

範例程式碼

在本程式碼研究室中,在 activity 和 intent 程式碼研究室的結束時,您就能使用 Word 應用程式接續先前的進度。如果您已完成 activity 與 intent 程式碼研究室,請隨時使用您的程式碼作為起點。或者,直到此刻,您也可以從 GitHub 下載程式碼。

下載本程式碼研究室的範例程式碼

本程式碼研究室提供範例程式碼,可延伸至本程式碼研究室所教授的功能。範例程式碼可能包含先前介紹過的程式碼。也可能含有您不熟悉的程式碼,您可以在後續的程式碼研究室中學習。

如果您使用 GitHub 中的範例程式碼,請注意資料夾名稱是 android-basics-kotlin-words-app-activities。在 Android Studio 中開啟專案時,請選取這個資料夾。

範例程式碼網址: https://github.com/google-developer-training/android-basics-kotlin-words-app
含有範例程式碼的模組名稱: activities


Fragment 與 Fragment 生命週期

片段(fragment)僅僅是一段可重複使用的應用程式使用者介面 如同 activity,fragment 具有生命週期且可回應使用者輸入。在畫面上顯示 activity 的 view 區塊階層內始終包含 fragment。由於 fragment 強調可重用性和模組化,甚至可以由單一 activity 同時代管多個 fragment,所以每個 fragment 都有各自的生命週期。

Fragment 生命週期

如同 activity,您也可以從記憶體初始化及移除 fragment,並且在 fragment 存在期間,會在螢幕上顯示、消失和重新顯示。此外,如同 activity,fragment 的生命週期有數個狀態,並提供多種覆寫方法,以回應 fragment 之間的轉換。fragment 生命週期為五個狀態,以 Lifecycle.State 列舉表示。

  • INITIALIZED (已初始化): fragment 的新 instance 已實例化(instantiated)
  • CREATED (已建立): 呼叫第一個 fragment 生命週期方法。在此狀態下,系統也會建立與 fragment 相關聯的 view
  • STARTED (已啟動): fragment 在畫面上可見,但沒有焦點(focus),因此無法回應使用者輸入。
  • RESUMED (已重新啟用): fragment 在畫面上可見,且有焦點(focus)
  • DESTROYED (已刪除): fragment 物件已解除實例化(de-instantiated)

也類似於活動,Fragment 類別提供多種方法,讓您可回應於生命週期事件進行覆寫。

  • onCreate():fragment 已實例化(instantiated),並處於 CREATED 狀態。不過,尚未建立對應的 view。
  • onCreateView():這個方法是加載 layout 之處。fragment 已進入 CREATED 狀態。
  • onViewCreated():會在建立 view 後呼叫。在此方法中,您通常會呼叫 findViewById() 來綁定(bind)特定 view 與屬性。
  • onStart():fragment 已進入 STARTED 狀態。
  • onResume():fragment 已進入 RESUMED 狀態且現在已聚焦(focus) (可回應使用者輸入)。
  • onPause():fragment 已重新進入 STARTED 狀態。使用者可看得到使用者介面
  • onStop():fragment 已重新進入 CREATED 狀態。物件已實例化(instantiated),但不再顯示在畫面上。
  • onDestroyView():在 fragment 正好進入 DESTROYED 狀態時呼叫。view 已從記憶體中移除,但fragment 物件仍然存在。
  • onDestroy():fragment 進入 DESTROYED 狀態。

下圖概述各種片段生命週期,以及各狀態之間的轉換。

生命週期狀態和 callback 方法非常類似於 activity 中使用的方法。不過,請記住與 onCreate() 方法的差異。配合 activity,使用此方法加載 layout 並 bind views。不過,在 fragment 生命週期內,系統會在建立 view 之前呼叫 onCreate(),因此您無法在這裡加載 layout。請改為在 onCreateView() 中執行這項操作。建立 view 之後,系統會呼叫 onViewCreated() 方法,然後將屬性綁定(bind)至特定 view。

儘管似乎很理論,但您現在已經瞭解 fragment 的基本運作方式,以及與各 activity 的相似及相異之處。在本程式碼研究室的其餘部分,您將充分學以致用。首先,您必須遷移先前使用 Words 應用程式至使用 fragment 為基礎的版面配置。接下來,導入在單一 activity 中不同 fragment 之間的導覽(navigation)功能。


建立 Fragment 和 layout 檔案

如同 activity,您新增的每個 fragment 都包含兩個檔案:一個檔案是 layout 的 XML 檔案,另一個檔案則是顯示資料和處理使用者互動的 Kotlin 類別。您必須新增字母列表和字詞列表的 fragment。

  1. 在「Project Navigator」(專案導覽器) 中選取「app」(應用程式),並加入下列片段 (「File」(檔案) >「New」(新增) >「Fragment」(片段) >「Fragment (Blank)」(片段 (空白))),應該會產生各 fragment 的類別layout 檔案
  • 將第一個 fragment 的「Fragment Name」(fragment名稱) 設定為 LetterListFragment。「Fragment Layout Name」(fragment layout 名稱) 應填入 fragment_letter_list
  • 將第二個 fragment 的「Fragment Name」(片段名稱) 設定為 WordListFragment。「Fragment Layout Name」(fragment layout 名稱) 應填入 fragment_word_list.xml
  1. 為這兩個 fragment 產生的 Kotlin 類別都包含許多範例程式碼,通常用於實作 fragment。不過,由於您是第一次使用 fragment,請從這兩個檔案中刪除所有內容,惟 LetterListFragmentWordListFragment 的類別宣告除外。我們會引導您您從頭開始逐步實作片段,使您瞭解所有程式碼的運作方式。刪除範例程式碼後,Kotlin 檔案應像如下所示。

LetterListFragment.kt

1
2
3
4
5
6
7
package com.example.wordsapp

import androidx.fragment.app.Fragment

class LetterListFragment : Fragment() {

}

WordListFragment.kt

1
2
3
4
5
6
7
package com.example.wordsapp

import androidx.fragment.app.Fragment

class WordListFragment : Fragment() {

}
  1. activity_main.xml 的內容複製到 fragment_letter_list.xml,並將 activity_detail.xml 的內容複製到 fragment_word_list.xml。將 fragment_letter_list.xml 中的 tools:context 更新為 .LetterListFragment,並將 fragment_word_list.xml 中的 tools:context 更新為 .WordListFragment

變更後,片段版面配置檔案應像如下所示。

fragment_letter_list.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".LetterListFragment">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp" />

</FrameLayout>

fragment_word_list.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".WordListFragment">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp"
tools:listitem="@layout/item_view" />

</FrameLayout>

導入 LetterListFragment

如同 activity,您需要加載 layout 置並綁定(bind)個別 view。使用 fragment 生命週期時,還是有些許差異。我們會引導您逐步完成 LetterListFragment 的設定程序,讓您有機會為 WordListFragment 進行相同設定。

若要在 LetterListFragment導入 view binding,您必須先取得 FragmentLetterListBinding 可為空值(nullable)的 reference(引用)。在 build.gradle 檔案的 buildFeatures 區段下啟用 viewBinding 屬性時,Android Studio 會為每個 layout 檔案產生與此類似的 Binding classes。您只需要為 FragmentLetterListBinding 中的每個 view 指派 fragment class 中的屬性

type 應為 FragmentLetterListBinding?,且初始值應為 null。為什麼要使其可為空值?因為除非呼叫 onCreateView(),否則您無法加載 layout。建立 LetterListFragment 的 instance (生命週期開始於 onCreate() ) 與到此屬性實際可用之間會有一段期間(period)。也請注意,您可以在 fragment 的生命週期內建立和刪除 fragment 的 view 數次。因此,您還必須重設在另一個生命週期方法 ( onDestroyView() ) 中的值。

  1. LetterListFragment.kt 中,首先獲取對 FragmentLetterListBinding 的引用(reference),並將引用(reference)命名為 _binding
1
private var _binding: FragmentLetterListBinding? = null

它可為空值,因此每次存取 _binding 的屬性 (例如 _binding?.someView) 時,您都必須納入 ? 來提供空值安全(null safety)。然而,這並不意謂您會因為一個空值,而捨棄有問號的程式碼。如果您確定某值在存取時不會為空值,則可以在類型名稱中附加 !!。於是您就不需使用 ? 運算子,也能和任何其他屬性一樣進行存取。

注意: 使用 !! 使變數可為空值時,限制只能在已知值不會是空值的一處或幾處使用該變數,就像您知道在 onCreateView() 中指派 _binding 之後將具有的值。以這種方式存取可為空值的值會有危險,並可能導致當機,因此請謹慎使用。

  1. 建立名為 binding 的新屬性 (不含底線),並將其設為等於 _binding!!
1
private val binding get() = _binding!!

此處,get() 意指屬性是「get-only」。這意指您可以「get」(取得) 這個值,但一旦指派 (如此處),就無法指派後給其他。

注意: 在 Kotlin 中或一般而言在程式設計時,您通常會看到屬性名稱後面接著底線。這通常意指屬性不適合直接存取。就您的情況而言,您會用 binding 屬性來存取 LetterListFragment 中的 view binding。不過,您不一定要在 LetterListFragment 外存取 _binding 屬性。

  1. 如要顯示 options menu,請覆寫 onCreate()。在 onCreate() 內呼叫 setHasOptionsMenu() 並傳入 true
1
2
3
4
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
  1. 請記住,使用 fragment 時,系統會在 onCreateView() 中加載 layout。加載 view、設定 _binding 的值,並傳回 root view,就可導入 onCreateView()
1
2
3
4
5
6
7
8
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentLetterListBinding.inflate(inflater, container, false)
val view = binding.root
return view
}
  1. binding 屬性下方,建立 recycler view 的屬性(property)。
1
private lateinit var recyclerView: RecyclerView
  1. 然後在 onViewCreated() 中設定 recyclerView 屬性的值,並呼叫 chooseLayout(),就像您在 MainActivity 中的做法一樣。您很快就會將 chooseLayout() 方法移到 LetterListFragment,所以不需擔心有錯誤。
1
2
3
4
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
recyclerView = binding.recyclerView
chooseLayout()
}

請注意,binding class 已經建立 recyclerView 的屬性,因此您不需要針對每個 view 呼叫 findViewById()

  1. 最後在 onDestroyView() 中,將 _binding 屬性重設為 null,因為 view 已不存在。
1
2
3
4
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
  1. 另外要注意的是,使用 fragment 時,onCreateOptionsMenu() 方法有些微的差異。雖然 Activity class 具有名為 menuInflater 的全域屬性(global property),但 Fragment 並未提供這項屬性(property),而是將 menu inflater 傳遞至 onCreateOptionsMenu() 中。另請注意,搭配 fragment 使用的 onCreateOptionsMenu() 方法不需要回傳 statement。實作方法如下所示:
1
2
3
4
5
6
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.layout_menu, menu)

val layoutButton = menu.findItem(R.id.action_switch_layout)
setIcon(layoutButton)
}
  1. MainActivity 中按原樣移動 chooseLayout()setIcon()onOptionsItemSelected() 的其餘程式碼。應注意的唯一差別在於,與 activity 不同,fragment 不是 Context。您無法傳入 this (指 fragment object) 做為 layout manager 的 context。但是,fragment 會提供 context 屬性,您可以改用該屬性。程式碼的其餘部分與 MainActivity 相同。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private fun chooseLayout() {
when (isLinearLayoutManager) {
true -> {
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.adapter = LetterAdapter()
}
false -> {
recyclerView.layoutManager = GridLayoutManager(context, 4)
recyclerView.adapter = LetterAdapter()
}
}
}

private fun setIcon(menuItem: MenuItem?) {
if (menuItem == null)
return

menuItem.icon =
if (isLinearLayoutManager)
ContextCompat.getDrawable(this.requireContext(), R.drawable.ic_grid_layout)
else ContextCompat.getDrawable(this.requireContext(), R.drawable.ic_linear_layout)
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_switch_layout -> {
isLinearLayoutManager = !isLinearLayoutManager
chooseLayout()
setIcon(item)

return true
}

else -> super.onOptionsItemSelected(item)
}
}

注意: requireContext() 會傳回目前與此 fragment 相關聯的 Context

  1. 最後,複製 MainActivityisLinearLayoutManager 屬性。將此屬性放在 recyclerView 屬性的宣告正下方。
1
private var isLinearLayoutManager = true
  1. 現在所有功能都已經移至 LetterListFragmentMainActivity class 所需要做的只是加載 layout,使得 fragment 顯示在 view 中。繼續從 MainActivity 刪除所有內容,惟 onCreate() 除外。變更後,MainActivity 只能包含下列項目。
1
2
3
4
5
6
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
}

將 DetailActivity 轉換為 WordListFragment

就是將 DetailActivity 轉換為 WordListFragment 和將 MainActivity 遷移至 LettersListFragment 的遷移作業幾乎相同。請執行下列步驟,將程式碼遷移至 WordListFragment

  1. 首先,請將 companion object 複製到 WordListFragment
1
2
3
4
companion object {
val LETTER = "letter"
val SEARCH_PREFIX = "https://www.google.com/search?q="
}
  1. 然後在 LetterAdapter 中,在執行 intent 的 onClickListener() 中,您必須將呼叫更新為 putExtra(),將 DetailActivity.LETTER 替換為 WordListFragment.LETTER
1
intent.putExtra(WordListFragment.LETTER, holder.button.text.toString())
  1. 同樣地,在 WordAdapter 中,在您前往字詞的搜尋結果時必須更新出現 onClickListener(),並將 DetailActivity.SEARCH_PREFIX 替換為 WordListFragment.SEARCH_PREFIX
1
val queryUrl: Uri = Uri.parse("${WordListFragment.SEARCH_PREFIX}${item}")
  1. 返回 WordListFragment,新增 type 為 FragmentWordListBinding?_binding 變數。變數應可為空值,並將 null 作為初始值。
1
private var _binding: FragmentWordListBinding? = null
  1. 然後新增名為 bindingget-only 變數,這樣無需使用 ? 就能引用 view。
1
private val binding get() = _binding!!
  1. 接著,請調整 layout,指派 _binding 變數並傳回 root view。請記得,對於 fragment,您在 onCreateView() 中執行此作業,而不是 onCreate()
1
2
3
4
5
6
7
8
9
10
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// 設定 _binding 的值
_binding = FragmentWordListBinding.inflate(inflater, container, false)
// 傳回 root view
return binding.root
}
  1. 接下來,您要導入 onViewCreated()。這幾乎和在 DetailActivity 中的 onCreate() 中設定 recyclerView 相同。不過,由於 fragment 無法直接存取 intent,因此您必須使用 activity.intent 進行引用。但是,您必須在 onViewCreated() 中執行此作業,因為無法保證在生命週期早期已存在 activity。
1
2
3
4
5
6
7
8
9
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val recyclerView = binding.recyclerView
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = WordAdapter(activity?.intent?.extras?.getString(LETTER).toString(), requireContext())

recyclerView.addItemDecoration(
DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
)
}
  1. 最後,您可以在 onDestroyView() 中將 _binding 重設為空值(null)。
1
2
3
4
5
override fun onDestroy() {
super.onDestroy()
// 將 _binding 屬性重設為 null,因為 view 已不存在
_binding = null
}
  1. 刪除 DetailActivity 中的其餘程式碼,只保留 onCreate() 方法。
1
2
3
4
5
6
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val binding = ActivityDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
}

移除 DetailActivity

現在,您已順利將 DetailActivity 的功能遷移至 WordListFragment,所以不再需要 DetailActivity。您可以繼續刪除 DetailActivity.ktactivity_detail.xml,也可以對資訊清單進行小幅變更。

  1. 首先,請刪除 DetailActivity.kt
  1. 確認已取消勾選「Safe Delete」,然後按一下「OK」。
  1. 接著刪除 activity_detail.xml。再次確認已取消勾選「Safe Delete」(安全刪除)。
  1. 最後,由於 DetailActivity 已不存在,請將下列項目從 AndroidManifest.xml 中移除。
1
2
3
<activity
android:name=".DetailActivity"
android:parentActivityName=".MainActivity" />

刪除 DetailActivity 後,剩下兩個 fragment ( LetterListFragmentWordListFragment ) 和單一 activity ( MainActivity )。下一節將介紹 Jetpack 導覽元件(navigation component) 並編輯 activity_main.xml,使得能夠顯示 fragment 及在 fragment 之間導覽,而不是代管靜態 layout。


Jetpack 導覽元件

Android Jetpack 提供導覽元件(Navigation component),可協助您在應用程式中處理任何簡易或複雜的導覽(navigation)實作。Navigation component 包含三個主要部分,可供您在 Words 應用程式中導入 navigation 功能。

  • Navigation GraphNavigation Graph 是可以在您的應用程式中提供 navigation 功能視覺表示XML 檔案。檔案包含對應於個別 activity 和 fragment 的「目的地」,以及在程式碼中可用於在目的地之間導覽(navigation)的動作。就如同 layout 檔案,Android Studio 提供視覺編輯器,可用於在 Navigation Graph 中加入目的地和動作。
  • NavHostNavHost 是用來顯示在 activity 內來自 Navigation Graph 的目的地。當您在 fragment 之間導覽時,NavHost 中顯示的目的地也會隨之更新。您需要在 MainActivity 中使用內建的實作,名為 NavHostFragment
  • NavControllerNavController 物件(object)可讓您控制 NavHost 中顯示的目的地之間的導覽(navigation)動作。使用 intent 時,您必須呼叫 startActivity 才能前往新的畫面。您可以使用 navigation component 呼叫 NavControllernavigate() 方法,調換所顯示的 fragmentNavController 也有助於您處理一般工作,例如回應系統「up」按鈕,即可返回先前顯示的 fragment。
  1. 在 project-level 的 build.gradle 檔案中,於 buildscript > extmaterial_version 下方,將 nav_version 設為等於 2.5.2
1
2
3
4
5
6
7
8
9
10
11
12
buildscript {
ext {
appcompat_version = "1.5.1"
constraintlayout_version = "2.1.4"
core_ktx_version = "1.9.0"
kotlin_version = "1.7.10"
material_version = "1.7.0-alpha2"
nav_version = "2.5.2"
}

...
}
  1. 在 app-level 的 build.gradle 檔案中,將以下內容加入 dependencies 群組:
1
2
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

Safe Args Plugin(外掛程式)

初次在 Words 應用程式中導入 navigation 時,您使用這兩種 activity 之間的 explicit intent(明確意圖)。若要在兩個 activity 之間傳遞資料,您必須呼叫 putExtra() 方法,並傳入所選字母。

將 navigation component 導入至 Words 應用程式之前,建議您也新增名為 Safe Args 的 Gradle plugin(外掛程式),協助您在 fragment 之間傳遞資料時確保 type 安全。

請執行下列步驟,將 SafeArgs 整合至您的專案

  1. 在 project-level 的 build.gradle 檔案中,於 buildscript > dependencies 新增下列類別路徑。
1
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
  1. 在 app-level 的 build.gradle 檔案中,在 plugins 內的頂端新增 androidx.navigation.safeargs.kotlin
1
2
3
4
5
6
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'androidx.navigation.safeargs.kotlin'
}
  1. 編輯 Gradle 檔案後,頁面頂端會顯示黃色橫幅,要求您同步處理專案。按一下「Sync Now」,等待一兩分鐘讓 Gradle 更新專案的 dependencies,反映所做變更。

同步完成後,您就可以進行下一步來新增 navigation graph。


使用 Navigation Graph

現在您對 fragments 和生命週期都有基本瞭解,接著就要開始更有趣的事情。下一步是納入 Navigation component。Navigation component 只是一系列導入尤其在 fragments 之間 navigation 的工具集合。您將使用新的 visual editor 協助導入 fragments 之間的 navigationNavigation Graph (簡稱 NavGraph)。

什麼是 Navigation Graph?

Navigation Graph (簡稱 NavGraph) 是應用程式 navigation 的虛擬對應。在這種情況下,每個 screen 或 fragment 都變成一個可以前往的可能「目的地」NavGraph 能以 XML 檔案表示,表明每個目的地彼此之間有何關聯

這項功能會在幕後建立 NavGraph class 的新 instance。不過,FragmentContainerView 會向使用者顯示 navigation graph 中的目的地。您只需建立 XML 檔案並定義可能的目的地即可。然後使用產生的程式碼以在 fragment 之間導覽。

在 MainActivity 中使用 FragmentContainerView

由於 layouts 已包含在 fragment_letter_list.xmlfragment_word_list.xml 中,因此 activity_main.xml 檔案不再需要包含應用程式中第一個畫面(screen)的 layout。而是重複利用 MainActivity 以包含 FragmentContainerView 來作為 fragment 的 NavHost。從現在開始,應用程式中的所有 navigation 動作都會發生在 FragmentContainerView 中。

  1. activity_main.xml (即 androidx.recyclerview.widget.RecyclerView) 中的 FrameLayout 內容替換為 FragmentContainerView。將此 ID 設定為 nav_host_fragment,並將高度和寬度設定為 match_parent,即可填滿整個 frame layout。

將下面的 code:

1
2
3
4
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
...
android:padding="16dp" />

替換為此:

1
2
3
4
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
  1. 在 ID 屬性下方新增 name 屬性,並設定為 androidx.navigation.fragment.NavHostFragment。雖然您可以針對此屬性指定特定 fragment,但設定為 NavHostFragment 即可讓 FragmentContainerView 在 fragment 之間導覽。
1
android:name="androidx.navigation.fragment.NavHostFragment"
  1. layout_heightlayout_width 屬性下方新增 app:defaultNavHost 屬性,並設定為等於 “true”。如此一來,fragment container 就可以與 navigation 階層互動。舉例來說,按下系統返回(back)按鈕後,container 就會回到先前顯示的 fragment,就像顯示新 activity 時的情況一樣。
1
app:defaultNavHost="true"
  1. 新增名為 app:navGraph 的屬性,並將該屬性設定為等於 “@navigation/nav_graph”。這會指向一個 XML 檔案,用於定義應用程式 fragment 之間的導覽方式。Android Studio 暫時會顯示尚未解決的符號錯誤。您會在接下來的工作中解決這個問題。
1
app:navGraph="@navigation/nav_graph"
  1. 最後,由於您使用應用程式 namespace 新增了兩項屬性,因此務必在 FrameLayout 中加入 xmlns:app 屬性。
1
2
3
4
5
6
<FrameLayout 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"
tools:context=".MainActivity">

這些就是 activity_main.xml 中的所有變更。接下來,您將建立 nav_graph 檔案。

設定 Navigation Graph

新增 navigation graph 檔案 (「File」(檔案)>「New」(新增) >「Android Resource File」(Android 資源檔案)),並按照以下方式填寫各欄位。

  • 檔案名稱:nav_graph.xml 這個名稱與您為 app:navGraph 屬性設定的名稱相同。
  • 資源類型:「Navigation」。系統隨即會將「Directory Name」自動變更為 navigation,然後建立名為「navigation」的新資源資料夾。

建立 XML 檔案時,系統會顯示新的視覺編輯器。由於您已引用 FragmentContainerViewnavGraph 屬性中的 nav_graph,如要新增目的地,請按一下畫面左上方的新增按鈕,然後為每個 fragment 建立目的地 (一個用於 fragment_letter_list,另一個用於 fragment_word_list)。

新增完成後,這些片段應該就會顯示在畫面中央的 navigation gragh 上。您也可以使用左側顯示的元件樹狀結構來選取特定目的地。

建立 Navigation action(動作)

如要建立 letterListFragmentwordListFragment 目的地之間的導覽動作,請將滑鼠游標懸停在 letterListFragment 目的地上,然後從右側顯示的圓圈拖曳至 wordListFragment 目的地。

現在您應該會看到已建立的箭頭,用來表示兩個目的地之間的動作。按一下箭頭,您就會在屬性窗格中看到可在程式碼中引用名稱為 action_letterListFragment_to_wordListFragment 的動作。

指定 WordListFragment 的 Arguments(參數)

使用 intent 在 activity 之間導覽(navigation)時,您指定了「extra」(額外項目),使得所選字母可傳遞到 wordListFragment。Navigation 也支援在目的地之間傳遞參數,並以 type 安全的方式完成這項操作。

選取 wordListFragment 目的地,然後在屬性窗格的 Arguments下方,按一下「+」按鈕建立新的參數(argument)。

參數(argument)應名為 letter,型別應為 String。您先前新增的 Safe Args 外掛程式就能派上用場。將這個引數(argument)指定為字串(String),可確保在程式碼中執行 navigation 動作時,String 會如預期運作。

設定起始目的地

當您的 NavGraph 知道所有需要的目的地時,FragmentContainerView 如何知道首先要顯示哪個 fragment? 在 NavGraph 中,您需要將字母列表設定為起始目的地。

如要設定起始目的地,請選取 letterListFragment,然後按一下「Assign start destination」按鈕。

這就是您目前需要在 NavGraph 編輯器執行的所有操作。此時,您就可以繼續並建立專案。在 Android Studio 中,從選單列依序選取「Build」>「Rebuild Project」。系統會根據您的 navigation graph 產生一些程式碼,方便您使用剛建立的 navigation 動作。

執行 Navigation Action

開啟 LetterAdapter.kt 以執行 navigation action(動作)。只需要兩個步驟即可完成。

  1. 刪除按鈕 setOnClickListener() 的內容。您必須改為擷取剛建立的 navigation action。將以下內容新增至 setOnClickListener()
1
val action = LetterListFragmentDirections.actionLetterListFragmentToWordListFragment(letter = holder.button.text.toString())

系統可能無法辨識部分的類別和函式名稱,原因是在建構專案後已自動產生這些名稱。如此一來,您在第一個步驟新增的 Safe Args 外掛程式就能派上用場,而在 NavGraph 中建立的動作(action)會轉為您可以使用的程式碼。然而,這些名稱應該要相當直覺易懂。LetterListFragmentDirections 表示從 letterListFragment 為起點開始的所有可能 navigation 路徑。

actionLetterListFragmentToWordListFragment() 函式是前往 wordListFragment 的特定動作。

對 navigation action 進行引用後,只需要取得「NavController」(可用來執行 navigation action 的物件) 的引用並呼叫 navigate() 傳入該動作(action)即可。

1
holder.view.findNavController().navigate(action)

設定 MainActivity

最後一段設定位於 MainActivity 中。僅需要少許變更 MainActivity 就可確保一切運作正常。

  1. 建立 navController 屬性。此標記為 lateinit,因為它會在 onCreate 中進行設定。
1
private lateinit var navController: NavController
  1. 然後,在 onCreate() 中呼叫 setContentView() 後,引用 nav_host_fragment (這是 FragmentContainerView 的 ID),並指派給 navController 屬性。
1
2
3
val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
navController = navHostFragment.navController
  1. 然後,在 onCreate() 中呼叫 setupActionBarWithNavController(),並傳入 navController。這可確保畫面上會顯示動作列(action bar/app bar) 按鈕(例如 LetterListFragment 中的 menu 選項)可見。
1
setupActionBarWithNavController(navController)
  1. 最後,導入 onSupportNavigateUp()。除了在 XML 中將 defaultNavHost 設定為 true 之外,此方法也可用來處理 up 按鈕。不過,您的 activity 必須提供實作。
1
2
3
override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp() || super.onSupportNavigateUp()
}

到目前為止,所有元件就緒,可以使用 fragment 進行導覽(navigation)。不過,現在 navigation 功能是使用 fragment 而非 intent 執行,因此您在 WordListFragment 中使用的字母的 intent extras 將不再有效。在下一個步驟中,您必須更新 WordListFragment 以取得 letter 參數。

注意: 由於 ,navigateUp() 函式可能會失敗,因此會傳回 Boolean 以指示是否成功。不過,只有在 navigateUp() 傳回 false 時,您才需要呼叫 super.onSupportNavigateUp()。由於 || 運算子只需要符合其中一個條件就為 true,所以仍然適用,因此如果 navigateUp() 傳回 true,則不會執行 || 運算式的右側。不過,如果 navigateUp()false,則會呼叫父項類別的實作。這稱為短路求值 (Short-circuit evaluation),是程式設計的必備小技巧。


獲取 WordListFragment 中的參數

您先前曾在 WordListFragment 中引用 activity?.intent 以存取 letter extra。這雖然有效,但不是最佳做法,因為 fragment 可以嵌入在其他 layout 中及大型應用程式中,這會很難假設 fragment 屬於哪個 activity。此外,使用 nav_graph 執行導覽且使用安全參數時是沒有 intent 的,因此嘗試存取 intent extra 並不可行。

幸好,存取安全參數非常簡單,無須等到 onViewCreated() 呼叫完畢。

  1. WordListFragment 中建立 letterId 屬性。您可以將此屬性標記為 lateinit,這樣就不必將它設為可為 null 了。
1
private lateinit var letterId: String
  1. 接著覆寫 onCreate() (不是 onCreateView()onViewCreated()!),並加入以下內容:
1
2
3
4
5
6
7
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

arguments?.let {
letterId = it.getString(LETTER).toString()
}
}

由於 arguments 有可能是可選的,因此請務必呼叫 let() 並傳入 lambda。這段程式碼會假設 arguments 不是空值(null),並傳入 it 參數的非空值(null)參數。不過,如果 argumentsnull,則 lambda 將不會執行。

雖然 Android Studio 不是實際程式碼之部分,但還提供實用的提示,讓您瞭解 it 參數。

Bundle 到底是什麼?可將其視為用在類別 (例如 activity 和 fragment) 之間傳遞資料的 key/value 組合。實際上,當您在這個應用程式的第一個版本中執行 intent 時,呼叫 intent?.extras?.getString() 時使用的就是 bundle。和使用 fragment 時,從參數中取得字串的方式完全相同。

  1. 最後,您可以在設定 recycler view 的 adapter 時存取 letterId。將 onViewCreated() 中的 activity?.intent?.extras?.getString(LETTER).toString() 替換成 letterId
1
recyclerView.adapter = WordAdapter(letterId, requireContext())

您成功了!請花點時間執行應用程式。現在,您可以在一個 activity 中,在兩個畫面之間進行導覽(navigation),而不需要使用任何 intent。


更新 Fragment Label

您已成功將這兩個畫面轉換成使用 fragment。在進行變更之前,每個 fragment 的 app bar 都會針對 app bar 中的每個 activity 提供描述性標題(descriptive title)。然而,一旦轉換成使用 fragment,這個標題就不會出現在 detail activity 中。

fragment 有一個名為 label 的屬性,您可以在其中設定父項 activity 要在 app bar 中使用的標題。

  1. strings.xml 中,在 app 名稱之後,新增下列常數。
1
<string name="word_list_fragment_label">Words That Start With {letter}</string>
  1. 您可以在 navigation graph 中設定每個 fragment 的 label。返回 nav_graph.xml 中,在 component 樹狀結構中選取 letterListFragment,然後前往屬性窗格將 label 設定為 app_name 字串:
  1. 選取 wordListFragment,並將 label 設定為 word_list_fragment_label

恭喜您完成目前為止的工作!請再執行一次應用程式,您應該會看到程式碼研究室開始時呈現的原貌,不過現在所有 navigation 動作都已透過單一 activity 代管,且每個畫面都設有獨立的 fragment。


總結

  • fragment 是可嵌入 activity 中可重複使用的使用者介面。
  • fragment 的生命週期與 activity 生命週期不同,而 view 設定發生在 onViewCreated()中,而不是 onCreateView()
  • FragmentContainerView 可用來將 fragment 嵌入至其他 activity 中,以及管理 fragment 之間的 navigation。

使用 navigation component

  • 設定 FragmentContainerViewnavGraph 屬性可讓您在 activity 內的不同 fragment 之間進行 navigation。
  • NavGraph 編輯器可讓您新增 navigation 動作,以及指定不同目的地的之間的參數(argument)。
  • 使用 intent 進行 navigation 時,您必須傳入 extra,navigation component 會使用 SafeArgs 自動為 navigation 動作產生類別和方法,以確保參數(argument)的 type 安全性。

fragment 的用途

  • 使用 navigation component 時,許多應用程式可在單一 activity 中管理整個 layout,且所有navigation 動作都在 fragment 之間進行。
  • fragment 可讓您使用常見的 layout 模式,例如平板電腦上的 master-detail layout,或是相同 activity 中的多個 fragment label。