【從零開始擼一個App】Fragment和導航中的使用

萊布尼茨發表於2021-02-20

Fragment簡介

Fragment自從Android 3.0引入開始,它所承擔的角色就是顯而易見的。它之於Activity就如html片段之於頁面,好處無需贅述。

Fragment例項由Activity的FragmentManager管理,其生命週期和Activity一樣,都不是由開發人員而是由系統維護的。自然而然的,每當它們被重建時,系統只會去呼叫它們的無參構造器,帶參構造器會被無視。那如果要在它們建立時傳入初始化資料咋辦呢?這也是為什麼會有Bundle這個玩意兒的存在,就是用於開發人員存取相關資料,如下所示:

/**
 * Use the [ThumbnailsFragment.newInstance] factory method to
 * create an instance of this fragment.
 */
class ThumbnailsFragment() : Fragment() {
    private var albumTag: String? = null

    companion object {
        @JvmStatic
        fun newInstance(albumTag: String?) =
            ThumbnailsFragment().apply {
                arguments = Bundle().apply {
                    putString("albumTag", albumTag)
                }
            }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            albumTag = it.getString("albumTag")
        }
    }
    
    /*other code*/
}

arguments由FragmentManager維護(跨fragment生命週期),可參看Android解惑 - 為什麼要用Fragment.setArguments(Bundle bundle)來傳遞引數

底部導航欄切換Fragment

效果如下

BottomNavigationView

底部是BottomNavigationView元件,各個選單在另外xml中定義,然後通過app:menu="xxx"指定。此處選單定義如下

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

    <item
        android:id="@+id/navigation_home"
        android:icon="@drawable/ic_home_black_24dp"
        android:title="@string/title_home" />

    <item
        android:id="@+id/navigation_dashboard"
        android:icon="@drawable/ic_dashboard_black_24dp"
        android:title="@string/title_dashboard" />

    <item
        android:id="@+id/navigation_notifications"
        android:icon="@drawable/ic_notifications_black_24dp"
        android:title="@string/title_notifications" />

</menu>

然後在程式碼中設定BottomNavigationView.setOnNavigationItemSelectedListener,判斷當前選中的選單項,手動切換Fragment,需要用到FragmentTransaction。如下示例

    override fun onClick(view: View?) {
        val trans = activity.supportFragmentManager.beginTransaction()
        val fragments = activity.supportFragmentManager.fragments
        fragments.forEach {
            if (it.isVisible) {
                trans.hide(it) //隱藏當前顯示的fragment
            }
        }
        val tag = (view as TextView).text.toString()
        val thumbnailsFragment = ThumbnailsFragment.newInstance(tag)
        //fragment_main_container就是居中的那塊區域,用於顯示各個fragment
        trans.add(R.id.fragment_main_container, thumbnailsFragment, tag)
        trans.show(thumbnailsFragment)
        trans.addToBackStack(null) //將本次操作入棧
        trans.commitAllowingStateLoss() //提交
    }

注意addToBackStack方法,該方法是為了實現回退時——使用者按返回按鈕或程式執行回退(配合popBackStack)——介面能返回到本次操作前的狀態。也可指定tag,在跨[多次]操作回退時有用。注意此處入棧的是操作資訊,而非fragment。

上述手動控制Fragment的切換太麻煩。2018 I/O大會上,Google隆重推出一個新的架構元件:Navigation。它提供了多Fragment之間的轉場、棧管理。在抽屜式/底部/頂部導航欄的需求中都可以使用這個元件。

使用:在res目錄下新建navigation資料夾,然後新建一個navigation graph設為bottom_navigation:

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/mobile_navigation"
    app:startDestination="@+id/navigation_home">

    <fragment
        android:id="@+id/navigation_home"
        android:name="com.eixout.presearchapplication.ui.home.HomeFragment"
        android:label="@string/title_home"
        tools:layout="@layout/fragment_home" />

    <fragment
        android:id="@+id/navigation_dashboard"
        android:name="com.eixout.presearchapplication.ui.dashboard.DashboardFragment"
        android:label="@string/title_dashboard"
        tools:layout="@layout/fragment_dashboard" />

    <fragment
        android:id="@+id/navigation_notifications"
        android:name="com.eixout.presearchapplication.ui.notifications.NotificationsFragment"
        android:label="@string/title_notifications"
        tools:layout="@layout/fragment_notifications" />
</navigation>

注意每個fragment的id要和之前定義的menu的id保持一致。可以設定轉場動畫,還可以設定每個fragment跳轉的目標(destination),目標可以是 Activity或Fragment,也可以自定義。

然後在Activity佈局檔案中新增一個Fragment,設定name屬性為android:name="androidx.navigation.fragment.NavHostFragment"。在傳統的單Activity多Fragment場景中,我們往往需要為Activity新增一個FrameLayout作為Fragment的容器。在Navigation中HavHostFragment就是Fragment的容器(HavHostFragment繼承了NavHost。The NavHost interface enables destinations to be swapped in and out.),其中設定app:navGraph="@navigation/bottom_navigation"使之與navigation graph建立聯絡。

    <fragment
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"        
        app:defaultNavHost="true"
        app:navGraph="@navigation/bottom_navigation"
        other_config="..." />

app:defaultNavHost: If set to true, the navigation host will intercept the Back button.

最後將導航欄與Navigation關聯

val navController = findNavController(R.id.nav_host_fragment)
bottomNavigationView.setupWithNavController(navController)

如此便大功告成了。

如果不依賴導航欄,而是手動跳轉,則可以使用NavController的相關方法,比如在Activity裡navController.navigate(actionId)

問題

Navigation和FragmentTransaction方式最好不要同時使用,它倆的返回堆疊似乎不是同一個,回退時會有問題。不能同時使用還使得下面兩個問題不好解決。

  1. 使用Navigation,Fragment可以相互跳轉沒問題,但狀態丟失了。比如A下滑一定距離後跳轉到B,B回退到A,A的下滑狀態丟失,仍是從頭部開始顯示。

  2. 每次點選BottomNavigationView的選單項,對應的Fragment會recreate,這其實不是我們想要的,我們預期的應該是Fragment第一次建立後就一直複用,既保留了當前狀態也不會對後端造成不必要的呼叫。

如果使用FragmentTransaction很好處理,只要快取一個Fragment集合即可(若要保留Fragment的狀態,比如滑動位置,可以使用supportFragmentManager.saveFragmentInstanceState(fragment)fragment.setInitialSavedState(savedState)載入,也可以使用hide/show(fragment)的方式),但用了Navigation後就沒辦法了。可以看看Navigation, Saving fragment state評論區的吐槽,裡面也有臨時的一些解決方案(不實用)。

FragmentTransaction本身也有對狀態資訊的處理考量,參看commit(), commitNow()和commitAllowingStateLoss()

參考資料

巢狀Fragment的使用及常見錯誤
Fragment 生命週期和使用
BottonNavigationView+Fragment切換toolbar標題欄
手把手教你使用Android官方元件Navigation
Playing with Navigation Architecture Components
The Navigation Architecture Component Tutorial: Getting Started
Handle Complex Navigation Flow with Single-Activity Architecture and Android Jetpack’s Navigation component
導航到目的地-popUpTo 和 popUpToInclusive
Difference between add(), replace(), and addToBackStack()

相關文章