Jetpack Compose の Navigation

1. はじめに

最終更新日: 2021 年 3 月 17 日

必要なもの

Navigation は、特定のルートに沿って、アプリ内のあるデスティネーションから別のデスティネーションにナビゲートできるようにする Jetpack ライブラリです。Navigation ライブラリには、Jetpack Compose でビルドされた画面内で一貫性のある自然なナビゲーションを可能にする、具体的なアーティファクトも用意されています。このアーティファクト(navigation-compose)は、この Codelab の中心的な役割を果たします。

演習内容

この Codelab では、ベースとして Rally Material スタディを使用します。既存のナビゲーション コードを移行して、Jetpack Compose の画面間のナビゲーションに Jetpack Navigation コンポーネントを使用します。

学習内容

  • Jetpack Compose で Jetpack Navigation を使用するための基本
  • コンポーザブル間のナビゲーション
  • 必須の引数と省略可能な引数を使用したナビゲーション
  • ディープリンクを使用したナビゲーション
  • TabBar のナビゲーション階層への統合
  • ナビゲーションのテスト

2. セットアップ

この Codelab は自分のマシンで進めることができます。

自分のマシンで進めるには、Codelab の出発点となるクローンを作成します。

$ git clone https://github.com/googlecodelabs/android-compose-codelabs.git

または、次の 2 つの zip ファイルをダウンロードします。

コードがダウンロードされたので、Android Studio で NavigationCodelab プロジェクトを開きます。これで準備が整いました。

3. Navigation に移行する

Rally は既存のアプリで、当初は Navigation を使用していません。移行は次の手順で行います。

  1. Navigation の依存関係を追加する
  2. NavController と NavHost をセットアップする
  3. デスティネーションのルートを準備する
  4. 元のデスティネーションのメカニズムをナビゲーション ルートに置き換える

Navigation の依存関係を追加する

アプリのビルドファイルを開きます(app/build.gradle にあります)。dependencies セクションで、navigation-compose の依存関係を追加します。

dependencies {
  implementation "androidx.navigation:navigation-compose:2.4.0-beta02"
  // other dependencies
}

これで、プロジェクトを同期して Compose の Navigation を使用する準備が整いました。

NavController をセットアップする

NavController は、Compose で Navigation を使用する場合の主要なコンポーネントです。バックスタック エントリの追跡、スタックの前進、バックスタック操作の有効化、画面状態間のナビゲーションを行います。NavController はナビゲーションの中心的存在であるため、デスティネーションにナビゲートするには、最初に作成する必要があります。

Compose 内では NavHostController を使用します。これは NavController のサブクラスです。rememberNavController() 関数を使用して NavController を取得します。これにより、構成変更後も存続する NavController が作成されて記憶されます(rememberSavable を使用)。NavController は、単一の NavHost コンポーザブルに関連付けられます。NavHost は、NavController と、コンポーザブルのデスティネーションが指定されているナビゲーション グラフをリンクします。

この Codelab では、NavController を取得して RallyApp 内に保存します。これは、アプリケーション全体のルート コンポーザブルとなり、RallyActivity.kt にあります。

import androidx.navigation.compose.rememberNavController
...

@Composable
fun RallyApp() {
    RallyTheme {
        val allScreens = RallyScreen.values().toList()
        var currentScreen by rememberSaveable { mutableStateOf(RallyScreen.Overview) }
        val navController = rememberNavController()
        Scaffold(...
}

デスティネーションのルートを準備する

概要

Rally アプリの画面は 3 つあります。

  1. Overview - すべての金融取引とアラートの概要
  2. Accounts - 既存の口座の分析情報
  3. Bills - 予定されている費用

アラート、口座、請求に関する情報が表示されている [Overview] 画面のスクリーンショット。 複数の口座の情報が表示されている [Accounts] 画面のスクリーンショット。 複数の請求の情報が表示されている [Bills] 画面のスクリーンショット。

3 つの画面はすべて、コンポーザブルを使用して作成されます。RallyScreen.kt をご覧ください。このファイルで 3 つの画面を宣言しています。後でこれらの画面をナビゲーション デスティネーションにマッピングし、開始デスティネーションとして Overview を使用します。また、コンポーザブルを RallyScreen から NavHost にナビゲートします。現在のところ、RallyScreen はそのままで構いません。

Compose 内で Navigation を使用する場合、ルートは文字列として表されます。この文字列は、URL やディープリンクと同様と考えることができます。この Codelab では、各 RallyScreen アイテムの name プロパティをルートとして使用します(例: RallyScreen.Overview.name)。

準備

RallyActivity.ktRallyApp コンポーザブルに戻り、画面のコンテンツを含む Box を新しく作成した NavHost に置き換えます。前の手順で作成した navController を渡します。NavHost には startDestination も必要になります。RallyScreen.Overview.name に設定します。また、Modifier を作成してパディングを NavHost に渡します。

import androidx.compose.foundation.layout.Box
import androidx.compose.material.Scaffold
import androidx.navigation.compose.NavHost
...

Scaffold(...) { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = RallyScreen.Overview.name,
            modifier = Modifier.padding(innerPadding)
        ) { ... }

これで、ナビゲーション グラフを定義できるようになりました。NavHost がナビゲートできるデスティネーションで、デスティネーションを受け入れる準備が整っています。そのためには、NavHost の最後のパラメータに指定される NavGraphBuilder(グラフを定義するためのラムダ)を使用します。このパラメータは関数を想定しているため、後置のラムダでデスティネーションを宣言できます。Navigation Compose アーティファクトは、NavGraphBuilder.composable 拡張関数を提供します。グラフ内のナビゲーション デスティネーションを定義するのに使用します。

import androidx.navigation.compose.NavHost
...

NavHost(
    navController = navController,
    startDestination = RallyScreen.Overview.name,
    modifier = Modifier.padding(innerPadding)

) {
    composable(RallyScreen.Overview.name) { ... }
}

現在のところ、Text にはコンポーザブルのコンテンツとして画面の名前を一時的に設定します。次のステップでは、既存のコンポーザブルを使用します。

import androidx.compose.material.Text
import androidx.navigation.compose.composable
...

NavHost(
    navController = navController,
    startDestination = RallyScreen.Overview.name
    modifier = Modifier.padding(innerPadding)
) {
    composable(RallyScreen.Overview.name) {
      Text(text = RallyScreen.Overview.name)
    }

    // TODO: Add the other two screens
}

次に、Scaffold から currentScreen.content 呼び出しを削除してアプリを実行すると、開始デスティネーションの名前と上記のタブが表示されます。

最終的に次のような NavHost になります。

NavHost(
    navController = navController,
    startDestination = RallyScreen.Overview.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(RallyScreen.Overview.name) {
      Text(RallyScreen.Overview.name)
    }
    composable(RallyScreen.Accounts.name) {
        Text(RallyScreen.Accounts.name)
    }
    composable(RallyScreen.Bills.name) {
        Text(RallyScreen.Bills.name)
    }
}

NavHostScaffold 内の Box を置き換えることができるようになりました。Modifier を NavHost に渡して、innerPadding はそのままにします。

@Composable
fun RallyApp() {
    RallyTheme {
        val allScreens = RallyScreen.values().toList()
        // FIXME: This duplicate source of truth
        //  will be removed later.
        var currentScreen by rememberSaveable {
            mutableStateOf(RallyScreen.Overview)
        }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = allScreens,
                    onTabSelected = { screen -> currentScreen = screen },
                    currentScreen = currentScreen
                )
            }
        ) { innerPadding ->
            NavHost(
                navController = navController,
                startDestination = RallyScreen.Overview.name,
                modifier = Modifier.padding(innerPadding)) {
            }
        }
    }
}

この時点では、トップバーはまだ接続されていないため、タブをクリックしても表示されるコンポーザブルは変わりません。次のステップでは、これに対応します。

ナビゲーション バーの状態の変更を完全に統合する

このステップでは、RallyTabRow を接続し、現在の手動ナビゲーション コードを削除します。このステップが完了すると、ナビゲーション コンポーネントがルーティングを完全に処理するようになります。

RallyActivity では、RallyTabRow コンポーザブルに、タブがクリックされたときに onTabSelected というコールバックが用意されていることがわかります。選択した画面にナビゲートするには、navController を使用するように選択コードを更新します。

これだけで、ナビゲーションを使用して TabRow で画面にナビゲートできます。

@Composable
fun RallyApp() {
    RallyTheme {
        val allScreens = RallyScreen.values().toList()
        // FIXME: This duplicate source of truth
        //  will be removed later.
        var currentScreen by rememberSaveable {
            mutableStateOf(RallyScreen.Overview)
        }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = allScreens,
                    onTabSelected = { screen ->
                        navController.navigate(screen.name)
                },
                    currentScreen = currentScreen,
                )
            }

この変更により、currentScreen は更新されなくなります。このため、選択したアイテムの展開と折りたたみは機能しません。この動作を再度有効にするには、currentScreen プロパティも更新する必要があります。Navigation はバックスタックを保持し、State として現在のバックスタック エントリを提供します。この State によって、バックスタックの変更に対応できます。また、現在のバックスタック エントリにそのルートをクエリすることも可能です。

TabRow の画面選択を Navigation に移行するには、以下のようにナビゲーション バックスタックを使用するように currentScreen を更新します。

import androidx.navigation.compose.currentBackStackEntryAsState
...

@Composable
fun RallyApp() {
    RallyTheme {
        val allScreens = RallyScreen.values().toList()
        val navController = rememberNavController()
        val backstackEntry = navController.currentBackStackEntryAsState()
        val currentScreen = RallyScreen.fromRoute(
            backstackEntry.value?.destination?.route
        )
        ...
    }
}

この時点でアプリを実行すると、タブを使用して画面を切り替えることができますが、表示されるのは画面名のみです。画面を表示するには、RallyScreen をナビゲーションに移行する必要があります。

RallyScreen を Navigation に移行する

このステップが完了すると、コンポーザブルは RallyScreen 列挙型から完全に切り離され、NavHost に移行します。RallyScreen は、画面のアイコンとタイトルを提供するためだけに存在します。

RallyScreen.kt を開きます。各画面の body の実装を、RallyAppNavHost 内にある対応するコンポーザブルに移行させます。

import com.example.compose.rally.data.UserData
import com.example.compose.rally.ui.accounts.AccountsBody
import com.example.compose.rally.ui.bills.BillsBody
import com.example.compose.rally.ui.overview.OverviewBody
...

NavHost(
    navController = navController,
    startDestination = Overview.name,
    modifier = Modifier.padding(innerPadding)
) {

    composable(Overview.name) {
        OverviewBody()
    }
    composable(Accounts.name) {
        AccountsBody(accounts = UserData.accounts)
    }
    composable(Bills.name) {
        BillsBody(bills = UserData.bills)
    }
}

この時点で、content 関数、body パラメータ、その使用を RallyScreen から安全に削除することができ、次のようなコードが残ります。

enum class RallyScreen(
    val icon: ImageVector,
) {
    Overview(
        icon = Icons.Filled.PieChart,
    ),
    Accounts(
        icon = Icons.Filled.AttachMoney,
    ),
    Bills(
        icon = Icons.Filled.MoneyOff,
    );

    companion object {
        ...
    }
}

アプリを再度実行すると、元の 3 つの画面が表示され、TabRow を使って画面間をナビゲートできます。

OverviewScreen でクリックを有効にする

この Codelab では、当初 OverviewBody のクリック イベントは無視されていました。つまり、[SEE ALL] ボタンはクリック可能でしたが、どこにもナビゲートされませんでした。

[Overview] 画面の画面録画。目的のクリック先までスクロールし、クリックを試行しています。クリックはまだ実装されていないため、機能しません。

これを修正しましょう。

OverviewBody は、クリック イベントへのコールバックとしていくつかの関数を使用できます。onClickSeeAllAccountsonClickSeeAllBills を実装して、適切なデスティネーションにナビゲートしましょう。

[See all] ボタンがクリックされたときにナビゲーションを有効にするには、navController を使用して、[Account] または [Bills] のいずれかの画面にナビゲートします。RallyActivity.kt を開き、NavHost 内で OverviewBody を探して、ナビゲーション呼び出しを追加します。

OverviewBody(
    onClickSeeAllAccounts = { navController.navigate(Accounts.name) },
    onClickSeeAllBills = { navController.navigate(Bills.name) },
)

これで OverviewBody のクリック イベントの動作を簡単に変更できるようになりました。navController をナビゲーション階層のトップレベルに配置し、OverviewBody に直接渡さないことで、実際の navController が存在しているかどうかに依存せずに、OverviewBody を単独で簡単にプレビューまたはテストできます。

4. 引数によるナビゲーション

Rally に新機能を追加しましょう。行をクリックすると口座の詳細情報を表示する、[Accounts] 画面を追加します。

ナビゲーション引数により、ルートが動的になります。ナビゲーション引数は、1 つ以上の引数をルートに渡し、引数のタイプやデフォルト値を調整することで、ルーティング動作を動的にする非常に強力なツールです。

RallyActivity では、引数 Accounts/{name} を使用して既存の NavHost に新しいコンポーザブルを追加して、グラフに新しいデスティネーションを追加します。このデスティネーションに対しては、navArgument のリストも指定します。「name」という引数を String 型として 1 つ定義します。

import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navArgument
...

val accountsName = RallyScreen.Accounts.name

composable(
    route = "$accountsName/{name}",
    arguments = listOf(
        navArgument("name") {
            // Make argument type safe
            type = NavType.StringType
        }
    )
) {
    // TODO
}

composable デスティネーションの本体で、現在のデスティネーションのルートと引数をモデル化する現在の NavBackStackEntry のパラメータ(これまで使用していません)を受け取ります。arguments を使用して、選択した口座の名前などの引数を取得し、これを UserData で探して SingleAccountBody コンポーザブルに渡すことができます。

引数が指定されていない場合に使用するデフォルト値を指定することもできます。ここでは必要ないので省略します。

コードは次のようになります。

import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navArgument
...

val accountsName = RallyScreen.Accounts.name
NavHost(...) {
    ...
    composable(
        "$accountsName/{name}",
        arguments = listOf(
            navArgument("name") {
                // Make argument type safe
                type = NavType.StringType
            }
        )
    ) { entry -> // Look up "name" in NavBackStackEntry's arguments
        val accountName = entry.arguments?.getString("name")
        // Find first name match in UserData
        val account = UserData.getAccount(accountName)
        // Pass account to SingleAccountBody
        SingleAccountBody(account = account)
    }
}

コンポーザブルに引数が設定されたので、navController.navigate("${RallyScreen.Accounts.name}/$accountName") のように navController を使用してコンポーザブルにナビゲートできます。

この関数を、NavHost 内にある OverviewBody の宣言の onAccountClick パラメータと、AccountsBodyonAccountClick に追加します。

再利用可能にするために、次のようなプライベート ヘルパー関数を作成します。

fun RallyNavHost(
    ...
) {
    NavHost(
        ...
    ) {
        composable(Overview.name) {
            OverviewBody(
                ...
                onAccountClick = { name ->
                    navigateToSingleAccount(navController, name)
                },
            )
        }
        composable(Accounts.name) {
            AccountsBody(accounts = UserData.accounts) { name ->
                navigateToSingleAccount(
                    navController = navController,
                    accountName = name
                )
            }
        }
        ...
    }
}

private fun navigateToSingleAccount(
    navController: NavHostController,
    accountName: String
) {
    navController.navigate("${Accounts.name}/$accountName")
}

この時点でアプリを実行すると、各口座をクリックすることができ、その口座のデータを示す画面が表示されます。

[Overview] 画面の画面録画。目的のクリック先までスクロールし、クリックを試行しています。クリックするとリンク先が表示されるようになりました。

5. ディープリンクのサポートを有効にする

引数だけでなくディープリンクを使用して、アプリ内のデスティネーションをサードパーティ アプリに公開することもできます。このセクションでは、前のセクションで作成したルートに新しいディープリンクを追加し、アプリ外部から個々の口座に名前で直接ディープリンクできるようにします。

インテント フィルタを追加する

まず、AndroidManifest.xml にディープリンクを追加します。アクション VIEW、カテゴリ BROWSABLEDEFAULT を設定した RallyActivity の新しいインテント フィルタを作成する必要があります。

次に、data タグを使用して、schemehostpathPrefix を追加します。

この Codelab では、ディープリンク URL として rally://accounts/{name} を使用します。

AndroidManifest で「name」引数を宣言する必要はありません。Navigation によって引数として解析されます。

<activity
    android:name=".RallyActivity"
    android:windowSoftInputMode="adjustResize"
    android:label="@string/app_name"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="rally" android:host="accounts" />
    </intent-filter>
</activity>

これで、RallyActivity の中で、受信したインテントに対応できるようになりました。

先ほど引数を受け入れるために作成したコンポーザブルは、新たに作成したディープリンクを受け入れることもできます。

navDeepLink 関数を使用して deepLinks のリストを追加します。uriPattern を渡して、上記の intent-filter に一致する URI を指定します。deepLinks パラメータを使用して、作成したディープリンクをコンポーザブルに渡します。

val accountsName = RallyScreen.Accounts.name

composable(
    "$accountsName/{name}",
    arguments = listOf(
        navArgument("name") {
            type = NavType.StringType
        },
    ),
    deepLinks =  listOf(navDeepLink {
        uriPattern = "rally://$accountsName/{name}"
    })
)

これで、アプリでディープリンクを処理できるようになりました。正しく動作するかどうかをテストするには、エミュレータまたはデバイスに現行バージョンの Rally をインストールし、コマンドラインを開いて以下のコマンドを実行します。

adb shell am start -d "rally://accounts/Checking" -a android.intent.action.VIEW

これにより、当座預金口座に直接アクセスでき、アプリ内のすべての口座名に対応します。

6. 完成した NavHost を抽出する

これで NavHost が完成しました。これを RallyApp コンポーザブルから独自の関数に抽出し、RallyNavHost という名前にします。これは、navController を直接操作する必要がある唯一のコンポーザブルです。RallyNavHost 内に navController を作成しないことで、上位構造の一部であるタブ選択を RallyApp 内で行うことが可能になります。

@Composable
fun RallyNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(
        navController = navController,
        startDestination = Overview.name,
        modifier = modifier
    ) {
        composable(Overview.name) {
            OverviewBody(
                onClickSeeAllAccounts = { navController.navigate(Accounts.name) },
                onClickSeeAllBills = { navController.navigate(Bills.name) },
                onAccountClick = { name ->
                    navController.navigate("${Accounts.name}/$name")
                },
            )
        }
        composable(Accounts.name) {
            AccountsBody(accounts = UserData.accounts) { name ->
                navController.navigate("Accounts/${name}")
            }
        }
        composable(Bills.name) {
            BillsBody(bills = UserData.bills)
        }
        val accountsName = Accounts.name
        composable(
            "$accountsName/{name}",
            arguments = listOf(
                navArgument("name") {
                    type = NavType.StringType
                },
            ),
            deepLinks = listOf(navDeepLink {
                uriPattern = "example://rally/$accountsName/{name}"
            }),
        ) { entry ->
            val accountName = entry.arguments?.getString("name")
            val account = UserData.getAccount(accountName)
            SingleAccountBody(account = account)
        }
    }
}

また、元の呼び出しサイトを RallyNavHost(navController) に置き換えて、意図したとおりに動作させるようにしてください。

fun RallyApp() {
    RallyTheme {
    ...
        Scaffold(
        ...
        ) { innerPadding ->
            RallyNavHost(
                navController = navController,
                modifier = Modifier.padding(innerPadding)
            )

        }
     }
}

7. Compose の Navigation をテストする

この Codelab では、当初から navController を直接コンポーザブルに渡すのではなく、コールバックをパラメータとして渡しています。つまり、すべてのコンポーザブルは個別にテストできます。ただし NavHost 全体をテストすることもできます。それがこのステップの目的です。コンポーザブル関数を個別にテストするには、Jetpack Compose でのテストの Codelab をご覧ください。

テストクラスを準備する

NavHost は、アクティビティ自体とは別にテストできます。

このテストは引き続き Android デバイスで実行されるため、/app/src/androidTest/java/com/example/compose/rally 下にある androidTest ディレクトリにテストファイルを作成する必要があります。

名前は RallyNavHostTest とします。

次に、Compose のテスト API を使用するために、以下のような Compose テストルールを作成します。

import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule

class RallyNavHostTest {

    @get:Rule
    val composeTestRule = createComposeRule()

}

これで、実際のテストを作成する準備が整いました。

初めてのテストを作成する

テスト関数を作成します。この関数は public とし、@Test アノテーションを付ける必要があります。この関数では、テストする内容を設定する必要があります。これを行うには、composeTestRulesetContent を使用します。コンポーザブルのパラメータを受け取り、通常のアプリの場合と同じように Compose コードを記述できます。RallyActivity と同様に RallyNavHost を設定します。

import androidx.navigation.compose.rememberNavController
import org.junit.Assert.fail
import org.junit.Test
...

class RallyNavHostTest {

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

    @Test
    fun rallyNavHost() {
        composeTestRule.setContent {
            navController = rememberNavController()
            RallyNavHost(navController = navController)
        }
        fail()
    }
}

上記のコードをコピーした場合、fail() 呼び出しにより、実際のアサーションが行われるまでテストが失敗します。これは、テストの実装を完了するためのリマインダーになります。

正しい画面が表示されているかどうかは、コンテンツの説明で確認できます。この Codelab では、"Accounts Screen""Overview Screen" のコンテンツの説明を提供しているため、テスト検証に利用できます。テストクラス自体に lateinit プロパティを作成し、今後のテストでも使用できるようにします。

簡単に開始するには、OverviewScreen が表示されていることを確認します。

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.navigation.NavHostController
...

class RallyNavHostTest {

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

    @Test
    fun rallyNavHost() {
        composeTestRule.setContent {
            navController = rememberNavController()
            RallyNavHost(navController = navController)
        }
        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

fail() 呼び出しを削除し、テストを再度実行すると、当然合格となります。

以下の各テストで、RallyNavHost は同じ方法で設定されます。したがって、これを @Before アノテーションが付いた関数に抽出し、コードをクリーンに保つことができます。

import org.junit.Before
...

class RallyNavHostTest {

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

    @Before
    fun setupRallyNavHost() {
        composeTestRule.setContent {
            navController = rememberNavController()
            RallyNavHost(navController = navController)
        }
    }

    @Test
    fun rallyNavHost() {
        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

ナビゲーションの実装のテストには複数の方法があり、UI 要素をクリックして新しいデスティネーションにナビゲートするか、対応するルート名を指定して navigate を呼び出します。

UI とテストルールを使用してテストする

アプリの実装をテストする場合は、UI をクリックすることをおすすめします。[All Accounts] ボタンをクリックして [Accounts] 画面に遷移し、適切な画面が表示されることを確認するテストを作成します。

import androidx.compose.ui.test.performClick
...

@Test
fun rallyNavHost_navigateToAllAccounts_viaUI() {
    composeTestRule
        .onNodeWithContentDescription("All Accounts")
        .performClick()
    composeTestRule
        .onNodeWithContentDescription("Accounts Screen")
        .assertIsDisplayed()
}

UI と navController を使用してテストする

navController を使用してアサーションを確認することもできます。これを行うには、UI をクリックし、backstackEntry.value?.destination?.route を使用して現在のルートと意図するルートを比較します。

import androidx.compose.ui.test.performScrollTo
import org.junit.Assert.assertEquals
...

@Test
fun rallyNavHost_navigateToBills_viaUI() {
    // When click on "All Bills"
    composeTestRule.onNodeWithContentDescription("All Bills").apply {
        performScrollTo()
        performClick()
    }
    // Then the route is "Bills"
    val route = navController.currentBackStackEntry?.destination?.route
    assertEquals(route, "Bills")
}

navController を使用してテストする

3 つ目の方法は、navController.navigate を直接呼び出すことですが、1 つ注意点があります。navController.navigate の呼び出しは UI スレッドで行う必要があります。これを実現するには、CoroutinesMain スレッド ディスパッチャを使用します。また、これは新しい状態についてのアサーションを作成する前に呼び出す必要があるため、runBlocking 呼び出しでラップする必要があります。

runBlocking {
    withContext(Dispatchers.Main) {
        navController.navigate(RallyScreen.Accounts.name)
    }
}

これにより、アプリ内をナビゲートして、意図したとおりにルートが機能することを確認できます。

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
...

@Test
fun rallyNavHost_navigateToAllAccounts_callingNavigate() {
    runBlocking {
        withContext(Dispatchers.Main) {
            navController.navigate(RallyScreen.Accounts.name)
        }
    }
    composeTestRule
        .onNodeWithContentDescription("Accounts Screen")
        .assertIsDisplayed()
}

Compose でのテストについて詳しくは、後述の「次のステップ」に示すリンク先の Codelab をご覧ください。

8. 完了

これで、この Codelab は終了です。

Rally アプリに Navigation を追加し、Jetpack Compose でナビゲーションを使用する際の重要な概念を理解しました。コンポーザブル デスティネーションのナビゲーション グラフを作成する方法、ルートに引数を追加する方法、ディープリンクを追加する方法、さまざまな手段で実装をテストする方法について学習しました。

次のステップ

以下の Codelab をご覧ください。

リファレンス ドキュメント