使用 Compose 進行導覽

Navigation 元件可支援 Jetpack Compose 應用程式。您可以在不同可組合項之間進行導覽,同時也可運用 Navigation 元件的基礎架構和功能。

設定

如要支援 Compose,請在應用程式模組的 build.gradle 檔案中使用下列依附元件:

Groovy

dependencies {
    def nav_version = "2.8.4"

    implementation "androidx.navigation:navigation-compose:$nav_version"
}

Kotlin

dependencies {
    val nav_version = "2.8.4"

    implementation("androidx.navigation:navigation-compose:$nav_version")
}

開始使用

在應用程式中實作導覽功能時,請實作導覽主機、圖表和控制器。詳情請參閱「導覽」總覽。

如要瞭解如何在 Compose 中建立 NavController,請參閱「建立導覽控制器」一文的 Compose 部分。

建立 NavHost

如要瞭解如何在 Compose 中建立 NavHost,請參閱「設計導覽圖」中的 Compose 部分。

如要瞭解如何前往可組合項,請參閱架構說明文件中的「前往目的地」。

如要瞭解如何在可組合函式目的地之間傳遞引數,請參閱「設計導覽圖」中的 Compose 部分。

瀏覽時擷取複雜資料

強烈建議您在瀏覽時不要傳遞複雜的資料物件,而是在執行導覽動作時,將最少必要資訊 (例如專屬 ID 或其他形式的 ID) 做為引數傳遞:

// Pass only the user ID when navigating to a new destination as argument
navController.navigate(Profile(id = "user1234"))

應採用單一真實資訊來源 (例如資料層) 的形式儲存複雜物件。完成導覽後,一旦到達目的地,即可使用傳遞的 ID 從單一真實資訊來源載入必要資訊。如要擷取 ViewModel 中負責存取資料層的引數,請使用 ViewModelSavedStateHandle

class UserViewModel(
    savedStateHandle: SavedStateHandle,
    private val userInfoRepository: UserInfoRepository
) : ViewModel() {

    private val profile = savedStateHandle.toRoute<Profile>()

    // Fetch the relevant user information from the data layer,
    // ie. userInfoRepository, based on the passed userId argument
    private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(profile.id)

// …

}

這種方法有助於防止在設定變更期間發生資料遺失的情況,以及在相關物件更新或變化時出現不一致的問題。

如要深入瞭解為何應避免將複雜資料做為引數傳遞,以及支援的引數類型清單,請參閱在目的地之間傳遞資料

Navigation Compose 支援深層連結,這些連結也可以被定義為 composable() 函式的一部分。其 deepLinks 參數接受 NavDeepLink 物件清單,可以使用 navDeepLink() 方法快速建立:

@Serializable data class Profile(val id: String)
val uri = "https://www.example.com"

composable<Profile>(
  deepLinks = listOf(
    navDeepLink<Profile>(basePath = "$uri/profile")
  )
) { backStackEntry ->
  ProfileScreen(id = backStackEntry.toRoute<Profile>().id)
}

這些深層連結可讓您將特定網址、動作或 MIME 類型與可組合項建立關聯。根據預設,這些深層連結不會出現在外部應用程式中。如要這些深層連結可對外使用,您必須在應用程式的 manifest.xml 檔案中加入適當的 <intent-filter> 元素。如要啟用上述範例中的深層連結,請在資訊清單的 <activity> 元素中加入以下內容:

<activity …>
  <intent-filter>
    ...
    <data android:scheme="https" android:host="www.example.com" />
  </intent-filter>
</activity>

當其他應用程式觸發深層連結時,Navigation 會自動深層連結到該可組合項。

如果在可組合項中使用正確的深層連結,這些深層連結也能用來建構 PendingIntent

val id = "exampleId"
val context = LocalContext.current
val deepLinkIntent = Intent(
    Intent.ACTION_VIEW,
    "https://www.example.com/profile/$id".toUri(),
    context,
    MyActivity::class.java
)

val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
    addNextIntentWithParentStack(deepLinkIntent)
    getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
}

這樣一來,您就能以使用任何其他 PendingIntent 的方式使用這個 deepLinkPendingIntent,以便在深層連結目的地開啟應用程式。

巢狀導覽

如要瞭解如何建立巢狀導覽圖,請參閱「巢狀結構圖」。

與底部導覽列整合

您可以在可組合項階層中定義較高層級的 NavController,藉此將 Navigation 與底部導覽元件等其他元件建立連結。這樣做可讓您透過選取底部列中的圖示進行瀏覽。

如要使用 BottomNavigationBottomNavigationItem 元件,請在 Android 應用程式中加入 androidx.compose.material 依附元件。

Groovy

dependencies {
    implementation "androidx.compose.material:material:1.7.5"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

dependencies {
    implementation("androidx.compose.material:material:1.7.5")
}

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

如要將底部導覽列中的項目與導覽圖表中的路徑建立連結,建議您定義包含路徑類別和圖示的類別,例如下方顯示的 TopLevelRoute

data class TopLevelRoute<T : Any>(val name: String, val route: T, val icon: ImageVector)

然後將這些路徑放在清單中,供 BottomNavigationItem 使用:

val topLevelRoutes = listOf(
   TopLevelRoute("Profile", Profile, Icons.Profile),
   TopLevelRoute("Friends", Friends, Icons.Friends)
)

BottomNavigation 可組合項中,使用 currentBackStackEntryAsState() 函式取得目前的 NavBackStackEntry。此項目可讓您存取目前的 NavDestination。透過 NavDestination 階層,將該項目的路徑與當前目的地及其父項目的地的路徑相較 (藉此處理使用巢狀導覽時的情況),可以確定每個 BottomNavigationItem 的選取狀態。

該項目的路徑也用於將 onClick lambda 連線至 navigate 呼叫,這樣一來,輕觸項目便會前往該項目。透過使用 saveStaterestoreState 旗標,當您在底部導覽項目之間切換時,系統會正確儲存和還原該項目的狀態和返回堆疊。

val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      topLevelRoutes.forEach { topLevelRoute ->
        BottomNavigationItem(
          icon = { Icon(topLevelRoute.icon, contentDescription = topLevelRoute.name) },
          label = { Text(topLevelRoute.name) },
          selected = currentDestination?.hierarchy?.any { it.hasRoute(topLevelRoute.route::class) } == true,
          onClick = {
            navController.navigate(topLevelRoute.route) {
              // Pop up to the start destination of the graph to
              // avoid building up a large stack of destinations
              // on the back stack as users select items
              popUpTo(navController.graph.findStartDestination().id) {
                saveState = true
              }
              // Avoid multiple copies of the same destination when
              // reselecting the same item
              launchSingleTop = true
              // Restore state when reselecting a previously selected item
              restoreState = true
            }
          }
        )
      }
    }
  }
) { innerPadding ->
  NavHost(navController, startDestination = Profile, Modifier.padding(innerPadding)) {
    composable<Profile> { ProfileScreen(...) }
    composable<Friends> { FriendsScreen(...) }
  }
}

在這裡,您可以利用 NavController.currentBackStackEntryAsState() 方法從 NavHost 函式中移出 navController 狀態,並與 BottomNavigation 元件共用該資訊。這表示 BottomNavigation 會自動取得最新狀態。

互通性

如果您想將 Navigation 元件與 Compose 搭配使用,有兩種做法:

  • 使用片段的 Navigation 元件定義導覽圖表。
  • 使用 Compose 目的地在 Compose 中定義帶有 NavHost 的導覽圖表。只有在導覽圖表中的所有畫面都是可組合項時,才能採用這種做法。

因此,如要混合 Compose 和 Views 應用程式,建議您使用以片段為基礎的 Navigation 元件。片段能夠容納以 View 為基礎的畫面、Compose 畫面,以及同時使用 Views 和 Compose 的畫面。當 Compose 中包含每個片段的內容後,下一步就是將這些畫面與 Navigation Compose 建立連結,並移除所有片段。

如要變更 Compose 程式碼中的目的地,您必須公開可傳遞到階層中的任何一個可組合項並被其觸發:

@Composable
fun MyScreen(onNavigate: (Int) -> Unit) {
    Button(onClick = { onNavigate(R.id.nav_profile) } { /* ... */ }
}

在片段中,您可以找到 NavController 並瀏覽至目的地,藉此在 Compose 和片段式 Navigation 元件之間搭橋:

override fun onCreateView( /* ... */ ) {
    setContent {
        MyScreen(onNavigate = { dest -> findNavController().navigate(dest) })
    }
}

或者,您也可以將 NavController 傳遞給您的 Compose 階層。但是,公開簡單的函式可重複使用且可進行測試。

測試

將導覽程式碼與可組合目的地分離,以便分別測試每個可組合項,與 NavHost 可組合項分開。

這表示您不應將 navController 直接傳遞給任何可組合項,而是將導覽回呼做為參數傳遞。這樣一來,您所有可組合項都能獨立測試,因為這類測試不需要 navController 的執行個體。

composable lambda 提供的間接層級可讓您從可組合項中將 Navigation 程式碼區隔出來。這在兩個方面起作用:

  • 僅將剖析的引數傳遞至可組合項
  • 傳送應該由可組合項 (而非 NavController 本身) 觸發的 lambda 進行導覽。

舉例來說,ProfileScreen 可組合項可擷取 userId 做為輸入內容,讓使用者前往好友的個人資料頁面,簽名如下:

@Composable
fun ProfileScreen(
    userId: String,
    navigateToFriendProfile: (friendUserId: String) -> Unit
) {
 …
}

這樣一來,ProfileScreen 可組合項可獨立於 Navigation 運作,因此可以獨立對其進行測試。composable lambda 會封裝使用最少的邏輯,以彌補 Navigation API 與可組合項之間的差距:

@Serializable data class Profile(id: String)

composable<Profile> { backStackEntry ->
    val profile = backStackEntry.toRoute<Profile>()
    ProfileScreen(userId = profile.id) { friendUserId ->
        navController.navigate(route = Profile(id = friendUserId))
    }
}

建議您編寫涵蓋應用程式導覽要求的測試,測試 NavHost 時,導覽動作會傳遞至可組合項及個別畫面的可組合項。

測試 NavHost

如要開始測試 NavHost,請新增下列導覽測試依附元件:

dependencies {
// ...
  androidTestImplementation "androidx.navigation:navigation-testing:$navigationVersion"
  // ...
}

將應用程式的 NavHost 納入可接受 NavHostController 做為參數的可組合函式中。

@Composable
fun AppNavHost(navController: NavHostController){
  NavHost(navController = navController){ ... }
}

您現在可以傳遞導覽測試構件 TestNavHostController 的例項,藉此測試 AppNavHostNavHost 中定義的所有導覽邏輯。用於驗證應用程式起始目的地和 NavHost 的 UI 測試如下所示:

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    lateinit var navController: TestNavHostController

    @Before
    fun setupAppNavHost() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavHost(navController = navController)
        }
    }

    // Unit test
    @Test
    fun appNavHost_verifyStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Start Screen")
            .assertIsDisplayed()
    }
}

測試導覽動作

您可以透過多種方式測試導覽的實作情形,具體方法如下:點按 UI 元素,然後驗證顯示的目的地,或比較預期路徑和目前路徑。

如要測試導入應用程式,建議您按一下使用者介面。如要瞭解如何獨立測試個別可組合函式,請務必查看「在 Jetpack Compose 程式碼研究室中測試」的程式碼研究室。

您也可以使用 navControllercurrentBackStackEntry,使用 navController 來比較目前的路徑與預期路徑,藉此檢查斷言。

@Test
fun appNavHost_clickAllProfiles_navigateToProfiles() {
    composeTestRule.onNodeWithContentDescription("All Profiles")
        .performScrollTo()
        .performClick()

    assertTrue(navController.currentBackStackEntry?.destination?.hasRoute<Profile>() ?: false)
}

如要進一步瞭解 Compose 測試基本概念,請參閱「測試 Compose 版面配置」和「在 Jetpack Compose 程式碼研究室中測試」程式碼研究室。如要進一步瞭解導覽程式碼的進階測試,請參閱「測試導覽」指南。

瞭解詳情

如要進一步瞭解 Jetpack Navigation,請參閱「開始使用導覽元件」一文,或造訪 Jetpack Compose Navigation 程式碼研究室

如要瞭解如何設計應用程式導覽,以配合不同螢幕的大小、方向和板型規格,請參閱「回應式 UI 導覽功能」。

如要進一步瞭解模組化應用程式中進階 Compose 導覽的實作方式,包括巢狀結構圖和底部導覽列整合等概念,請參閱 GitHub 上的 Now in Android 應用程式。

範例