Jetpack Compose の Navigation

1. はじめに

最終更新日: 2022 年 7 月 25 日

必要なもの

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

演習内容

この Codelab では、ベースとして Rally マテリアルのスタディを使用して、Jetpack Navigation コンポーネントを実装し、コンポーザブルの Rally 画面間のナビゲーションを有効にします。

学習内容

  • Jetpack Compose で Jetpack Navigation を使用するための基本
  • コンポーザブル間のナビゲーション
  • カスタム タブバー コンポーザブルのナビゲーション階層への統合
  • 引数によるナビゲーション
  • ディープリンクを使用したナビゲーション
  • ナビゲーションのテスト

2. セットアップ

手順に沿って進めるにあたり、この Codelab の出発点(main ブランチ)のクローンを作成します。

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

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

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

3. Rally アプリの概要

まず、Rally アプリとそのコードベースをよく理解してください。アプリを実行して、少し操作してみましょう。

Rally には、コンポーザブルとして 3 つのメイン画面があります。

  1. OverviewScreen - すべての金融取引とアラートの概要
  2. AccountsScreen - 既存の口座の分析情報
  3. BillsScreen - 予定されている支出

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

画面最上部の Rally は、カスタム タブバー コンポーザブル(RallyTabRowを使用して、これら 3 つの画面をナビゲートしています。各アイコンをタップすると、現在の選択が展開され、対応する画面に移動します。

336ba66858ae3728.png e26281a555c5820d.png

これらのコンポーザブル画面に移動する場合は、特定の位置で各画面にアクセスする必要があることから、それらの画面をナビゲーション デスティネーションと考えることもできます。これらのデスティネーションは、RallyDestinations.kt ファイルで事前定義されています。

このファイル内で、オブジェクトとして定義されている 3 つの主要なデスティネーション(Overview, Accounts および Bills)と、後ほどアプリに追加する SingleAccount を確認できます。各オブジェクトは RallyDestination インターフェースから拡張され、ナビゲーションに必要な各デスティネーションに関する必要な情報を含みます。

  1. トップバーの icon
  2. String route(Compose Navigation で、対象のデスティネーションにアクセスするためのパスとして必須)
  3. このデスティネーションのコンポーザブル全体を表す screen

アプリを実行すると、現在トップバーを使用しているデスティネーション間を移動できますが、アプリは実際には Compose Navigation を使用していません。その代わりに、現在のナビゲーション メカニズムでは、コンポーザブルを手動で切り替え、再コンポジションをトリガーして新しいコンテンツを表示するように設定されています。したがって、この Codelab の目標は、Compose Navigation への移行とその実装を完了することです。

4. Compose Navigation に移行する

Jetpack Compose への基本的な移行の手順は次のとおりです。

  1. 最新の Compose Navigation の依存関係を追加する
  2. NavController をセットアップする
  3. NavHost を追加してナビゲーション グラフを作成する
  4. アプリの異なるデスティネーション間をナビゲートするためのルートを準備する
  5. 現在のナビゲーション メカニズムを Compose Navigation に置き換える

これらのステップを 1 つずつ詳しく確認しましょう。

Navigation の依存関係を追加する

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

dependencies {
  implementation "androidx.navigation:navigation-compose:{latest_version}"
  // ...
}

navigation-compose の最新バージョンはこちらにあります。

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

NavController をセットアップする

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

NavController を取得するには、rememberNavController() 関数を呼び出します。これにより、(rememberSaveable を使用して)構成変更後も存続する NavController が作成されて記憶されます。

NavController は常に、コンポーザブル階層の最上位(通常は App コンポーザブル内)に作成して配置します。これにより、NavController を参照する必要があるすべてのコンポーザブルにアクセス権が付与されます。これは状態ホイスティングの原則に従っており、NavController は、コンポーザブル画面間をナビゲートし、バックスタックを維持する際に信頼できる主な情報源として使用されます。

RallyActivity.kt を開きます。RallyApp 内で rememberNavController() を使用して、root コンポーザブルであり、アプリ全体のエントリ ポイントでもある NavController を取得します。

import androidx.navigation.compose.rememberNavController
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            // ...
        ) {
            // ...
       }
}

Compose Navigation でのルート

前述のように、Rally アプリには主なデスティネーションが 3 つあり、後ほどさらに 1 つ追加されます(SingleAccount)。これらは RallyDestinations.kt で定義されます。各デスティネーションに iconroutescreen が定義されていることを説明しました。

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

次のステップでは、これらのデスティネーションをナビゲーション グラフに追加し、Overview をアプリ起動時の最初のデスティネーションに設定します。

Compose 内で Navigation を使用する場合、ナビゲーション グラフ内の各コンポーザブルのデスティネーションにはルートが関連付けられます。ルートは、コンポーザブルへのパスを定義する String として表現され、navController が正しい場所に到達できるようにします。これは特定のデスティネーションにつながる暗黙的なディープリンクと考えることができます。デスティネーションごとに一意のルートを指定する必要があります

これを実現するには、各 RallyDestination オブジェクトの route プロパティを使用します。たとえば、Overview.routeOverview 画面コンポーザブルに移動するルートです。

ナビゲーション グラフを含む NavHost コンポーザブルを呼び出す

次のステップでは、NavHost を追加して、ナビゲーション グラフを作成します。

Navigation の主な要素は、NavControllerNavGraphNavHost の 3 つです。NavController は常に 1 つの NavHost コンポーザブルに関連付けられます。NavHost はコンテナとして機能し、グラフの現在のデスティネーションを表示します。コンポーザブル間を移動すると、NavHost のコンテンツは自動的に再コンポーズされます。また、NavHost は NavController をナビゲーション グラフ(NavGraph)にリンクします。ナビゲーション グラフは、コンポーザブルのデスティネーションをマッピングして、それらのデスティネーション間を移動できるようにします。NavHost は基本的に、取得可能なデスティネーションのコレクションです。

RallyActivity.ktRallyApp コンポーザブルに戻ります。画面を手動で切り替えるために現在の画面のコンテンツを格納する Scaffold 内の Box コンポーザブルを、以下のコード例に従って作成できる新しい NavHost に置き換えます。

前のステップで作成した navController を渡して、この NavHost に接続します。前述のように、各 NavController は 1 つの NavHost に関連付ける必要があります。

NavHost には、アプリの起動時に表示するデスティネーションを確認するために startDestination ルートも必要なため、これを Overview.route に設定します。また、Scaffold の外側のパディングを受け入れる Modifier を渡し、NavHost に適用します。

最後のパラメータ builder: NavGraphBuilder.() -> Unit は、ナビゲーション グラフを定義してビルドするためのものです。Navigation Kotlin DSL のラムダ構文を使用しているため、関数の本文内で後置ラムダとして渡し、括弧の外に記述することができます。

import androidx.navigation.compose.NavHost
...

Scaffold(...) { innerPadding ->
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = Modifier.padding(innerPadding)
    ) {
       // builder parameter will be defined here as the graph
    }
}

NavGraph にデスティネーションを追加する

ここまでで、ナビゲーション グラフと、NavController がナビゲートするデスティネーションを定義できるようになりました。前述のとおり、builder パラメータは関数を想定しているため、Navigation Compose の NavGraphBuilder.composable 拡張関数を使用すると、コンポーズ可能な個別のデスティネーションをナビゲーション グラフに簡単に追加し、必要なナビゲーション情報を定義できます。

最初のデスティネーションを Overview にするため、composable 拡張関数を使用して追加し、一意の String route を設定する必要があります。これは単にデスティネーションをナビゲーション グラフに追加するだけであるため、このデスティネーションに移動したときに表示される実際の UI も定義する必要があります。この定義にも、composable 関数本文内の後置ラムダを使用します。この手法は、Compose で頻繁に使用されるパターンです。

import androidx.navigation.compose.composable
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
}

このパターンに沿って、3 つのメイン画面コンポーザブルすべてを 3 つのデスティネーションとして追加します。

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
    composable(route = Accounts.route) {
        Accounts.screen()
    }
    composable(route = Bills.route) {
        Bills.screen()
    }
}

アプリを実行すると、Overview が最初のデスティネーションとして表示され、対応する UI が表示されます。

先ほど、カスタムのトップタブバーである RallyTabRow コンポーザブルについて触れました。画面間の手動ナビゲーションはこのバーが処理していました。この時点ではまだ新しいナビゲーションに関連付けられていないため、タブをクリックしても、表示される画面コンポーザブルのデスティネーションが変更されないことを確認できます。次にこれを修正しましょう。

5. RallyTabRow をナビゲーションと統合する

このステップでは、RallyTabRownavController とナビゲーション グラフに接続して、正しいデスティネーションに移動できるようにします。

そのためには、新しい navController を使用して、RallyTabRowonTabSelected コールバックの適切なナビゲーション アクションを定義する必要があります。このコールバックは、特定のタブアイコンが選択されたときに行われる動作を定義し、navController.navigate(route). を介してナビゲーション アクションを実行します。

このガイダンスに沿って、RallyActivityRallyTabRow コンポーザブルとそのコールバック パラメータ onTabSelected を見つけます。

タブをタップした際に、特定のデスティネーションにナビゲートするように設定する必要があるため、選択されたタブアイコンを正確に把握することも必要です。幸いなことに、onTabSelected: (RallyDestination) -> Unit パラメータはこれをすでに可能にしています。この情報と RallyDestination ルートを使用して navController をガイドし、タブが選択されたときに navController.navigate(newScreen.route) を呼び出します。

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    // Pass the callback like this,
                    // defining the navigation action when a tab is selected:
                    onTabSelected = { newScreen ->
                        navController.navigate(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

アプリを実行し、RallyTabRow のタブをタップすると、それぞれの正しいコンポーザブルのデスティネーションにナビゲートされることを確認できます。ただし、現時点で判明している問題が 2 つあります。

  1. 特定の行の同じタブを再度タップすると、同じデスティネーションの複数のコピーが起動する
  2. タブの UI が、表示される正しいデスティネーションと一致していない - つまり、選択したタブの展開と折りたたみが意図したとおりに機能していない

336ba66858ae3728.png e26281a555c5820d.png

両方とも修正しましょう。

デスティネーションのコピーを 1 つだけ起動する

1 つ目の問題を解決しましょう。特定のデスティネーションのコピーがバックスタックの一番上に最大で 1 つだけ配置されるように、Compose Navigation API には launchSingleTop フラグが用意されています。このフラグを次のように navController.navigate() アクションに渡すことができます。

navController.navigate(route) { launchSingleTop = true }

この動作をアプリ全体で保持するため、各デスティネーションに対して、このフラグをコピーして .navigate(...) 呼び出しすべてに貼り付けるのではなく、RallyActivity の下部にあるヘルパー拡張関数に抽出できます。

import androidx.navigation.NavHostController
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

これで、navController.navigate(newScreen.route) の呼び出しを .navigateSingleTopTo(...) に置き換えることができます。アプリを再度実行し、上部のバーでアイコンを複数回クリックしても、単一のデスティネーションのコピーが 1 つだけ表示されることを確認してください。

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    onTabSelected = { newScreen ->
                        navController
                            .navigateSingleTopTo(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

ナビゲーション オプションとバックスタックの状態を制御する

launchSingleTop の他にも、ナビゲーションの動作をさらに詳細に制御およびカスタマイズするために NavOptionsBuilder で使用できるフラグがあります。RallyTabRowBottomNavigation と同様に動作するため、デスティネーション間を移動する際に、デスティネーションの状態を保存および復元するかどうかも検討する必要があります。たとえば、[Overview] の一番下までスクロールした後 [Accounts] に移動し、再び [Overview] に戻った場合に、スクロール位置を保持する必要があるでしょうか。RallyTabRow で同じデスティネーションを再度タップしたとき、画面の状態を再読み込みしますか。これらはすべて有効な質問であり、ご自身のアプリ設計の要件に基づいて判断する必要があります。

同じ navigateSingleTopTo 拡張関数内で使用できるその他のオプションについても説明します。

  • launchSingleTop = true - 前述のように、これにより、特定のデスティネーションのコピーがバックスタックの一番上に最大で 1 つ配置されます。
  • つまり、Rally アプリでは、同じタブを複数回タップしても、同じデスティネーションの複数のコピーは起動されません。
  • popUpTo(startDestination) { saveState = true } - タブを選択した際に、バックスタックに大量のデスティネーションが積み重なるのを回避するため、グラフの最初のデスティネーションまでポップします。
  • つまり Rally では、いずれかのデスティネーションで戻る矢印を押すと、バックスタック全体がポップされ、Overview に戻ります。
  • restoreState = true - そのナビゲーション アクションで、PopUpToBuilder.saveState または popUpToSaveState 属性によって以前に保存された状態を復元するかどうかを決定します。移動先のデスティネーション ID で以前に保存された状態が存在しない場合、これによる影響はありません
  • つまり Rally では、同じタブを再度タップしても再読み込みは行われず、それまでのデータとユーザーの状態が画面上に保持されます。

これらすべてのオプションをコードに 1 つずつ追加し、そのたびにアプリを実行して、各フラグを追加した後の具体的な動作を検証してください。これにより、各フラグがナビゲーションとバックスタックの状態をどのように変更するかを実際のアプリで確認できます。

import androidx.navigation.NavHostController
import androidx.navigation.NavGraph.Companion.findStartDestination
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) {
        popUpTo(
            this@navigateSingleTopTo.graph.findStartDestination().id
        ) {
            saveState = true
        }
        launchSingleTop = true
        restoreState = true
}

タブ UI を修正する

この Codelab のごく初期の段階では、手動ナビゲーション メカニズムを使用して、RallyTabRow では currentScreen 変数を使用して各タブの展開と折りたたみを決定していました。

しかし、これまでの変更によって currentScreen が更新されなくなったため、RallyTabRow 内で、選択したタブの展開と折りたたみが機能しなくなりました。

Compose Navigation を使用してこの動作を再度有効にするには、各時点で表示される現在のデスティネーション(ナビゲーションの観点から言い換えれば、現在のバックスタックの最上部エントリ)を認識し、それが変更されるたびに RallyTabRow を更新する必要があります。

バックスタックから現在のデスティネーションに関する最新情報を State の形式でリアルタイムに取得するには、navController.currentBackStackEntryAsState() を使用して現在の destination: を取得します。

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        // Fetch your currentDestination:
        val currentDestination = currentBackStack?.destination
        // ...
    }
}

currentBackStack?.destinationNavDestination. を返します。currentScreen をもう一度正しく更新するには、戻り値 NavDestination を Rally の 3 つのメイン画面コンポーザブルのいずれかに一致させる方法を見つける必要があります。RallyTabRow. にこの情報を渡せるように、現在表示されているコンポーザブルを判別する必要があります。前述のとおり、各デスティネーションには一意のルートがあります。この String ルートを一種の ID として使用して厳密な比較を行い、一意の一致を見つけることができます。

currentScreen を更新するには、rallyTabRowScreens リストを反復処理して一致するルートを見つけ、対応する RallyDestination を返す必要があります。Kotlin には、そのための簡便な .find() 関数が用意されています。

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        val currentDestination = currentBackStack?.destination

        // Change the variable to this and use Overview as a backup screen if this returns null
        val currentScreen = rallyTabRowScreens.find { it.route == currentDestination?.route } ?: Overview
        // ...
    }
}

currentScreen はすでに RallyTabRow に渡されているため、アプリを実行すると、それに応じてタブバーの UI が更新されていることを確認できます。

6. RallyDestination から画面コンポーザブルを抽出する

これまでは、わかりやすくするために、RallyDestination インターフェースの screen プロパティと、そこから拡張される画面オブジェクトを使用して、コンポーザブル UI を NavHost (RallyActivity.kt に追加していました。

import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
    // ...
}

ただし、この Codelab の以降のステップ(クリック イベントなど)では、コンポーザブル画面に追加情報を直接渡す必要があります。本番環境では間違いなく、渡す必要があるデータがさらに増加するでしょう。

これをクリーンに実現する適切な方法は、コンポーザブルを NavHost ナビゲーション グラフに直接追加し、RallyDestination から抽出することです。その後、RallyDestination とスクリーン オブジェクトはナビゲーション固有の情報(iconroute など)のみを保持し、Compose UI に関連するものから分離します。

RallyDestinations.kt を開きます。各画面のコンポーザブルを RallyDestination オブジェクトの screen パラメータから抽出して、NavHost 内の対応する composable 関数に抽出します。この処理によって、前の .screen() 呼び出しが置き換えられます。コードは次のようになります:

import com.example.compose.rally.ui.accounts.AccountsScreen
import com.example.compose.rally.ui.bills.BillsScreen
import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        OverviewScreen()
    }
    composable(route = Accounts.route) {
        AccountsScreen()
    }
    composable(route = Bills.route) {
        BillsScreen()
    }
}

この時点で、RallyDestination とそのオブジェクトから screen パラメータを安全に削除できます。

interface RallyDestination {
    val icon: ImageVector
    val route: String
}

/**
 * Rally app navigation destinations
 */
object Overview : RallyDestination {
    override val icon = Icons.Filled.PieChart
    override val route = "overview"
}
// ...

アプリを再度実行し、すべてが以前と同様に動作することを確認してください。これでこのステップは完了です。次は、コンポーザブル画面内にクリック イベントをセットアップしましょう。

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

現時点では、OverviewScreen でのクリック イベントはすべて無視されます。つまり、[Accounts] サブセクションと [Bills] サブセクションの [SEE ALL] ボタンはクリック可能ですが、実際には移動できません。このステップの目標は、これらのクリック イベントのナビゲーションを有効にすることです。

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

OverviewScreen コンポーザブルは、複数の関数をコールバックとして受け入れて、クリック イベントとして設定できます。この例では、AccountsScreen または BillsScreen に移動するナビゲーション アクションであることが必要です。正しいデスティネーションにナビゲートできるよう、これらのナビゲーション コールバックを onClickSeeAllAccountsonClickSeeAllBills に渡しましょう。

RallyActivity.kt を開き、NavHost 内で OverviewScreen を探して、対応するルートを持つ両方のナビゲーション コールバックに navController.navigateSingleTopTo(...) を渡します。

OverviewScreen(
    onClickSeeAllAccounts = {
        navController.navigateSingleTopTo(Accounts.route)
    },
    onClickSeeAllBills = {
        navController.navigateSingleTopTo(Bills.route)
    }
)

これで、navController はボタンのクリックで適切なデスティネーションに移動するための正確なデスティネーション , のルートなど、十分な情報を得ることができます。OverviewScreen の実装を確認すると、これらのコールバックがすでに、対応する onClick パラメータに設定されていることがわかります:

@Composable
fun OverviewScreen(...) {
    // ...
    AccountsCard(
        onClickSeeAll = onClickSeeAllAccounts,
        onAccountClick = onAccountClick
    )
    // ...
    BillsCard(
        onClickSeeAll = onClickSeeAllBills
    )
}

前述のとおり、navController をナビゲーション階層の最上位に配置し、App コンポーザブルのレベルにホイストした状態を保持する(OverviewScreen) などに直接渡さない)ことで、OverviewScreen コンポーザブルを単独で簡単にプレビュー、再利用、テストできます。その際に実際の、またはモックされた navController インスタンスを使用する必要はありません。コールバックを代わりに渡すことで、クリック イベントにも迅速に変更を加えることができます。

7. 引数を使用して SingleAccountScreen にナビゲートする

Accounts 画面と Overview 画面に新しい機能を追加しましょう。現在、これらの画面には「Checking」、「Home Savings」などのさまざまな種類の口座の一覧が表示されています。

2f335ceab09e449a.png 2e78a5e090e3fccb.png

ただし、これらの口座種別をクリックしても何も起こりません。この問題を修正しましょう。各口座種別をタップした際に、口座の詳細情報を含む新しい画面が表示されるようにします。そのためには、どの口座種別がクリックされたかを navController が認識できるよう、追加情報を提供する必要があります。これは、引数を使用して行うことができます。

引数は、1 つ以上の引数をルートに渡すことで、ナビゲーションのルーティング動作を動的にする非常に強力なツールです。指定する引数に応じてさまざまな情報を表示できます。

RallyApp で、既存の NavHost: に新しい composable 関数を追加することで、これらの個別の口座を表示する新しいデスティネーション SingleAccountScreen をグラフに追加します。

import com.example.compose.rally.ui.accounts.SingleAccountScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    ...
    composable(route = SingleAccount.route) {
        SingleAccountScreen()
    }
}

SingleAccountScreen ランディング デスティネーションをセットアップする

SingleAccountScreen にアクセスした際、このデスティネーションは、表示する口座種別を正確に把握するための追加情報を必要とします。この種類の情報は、引数を使用して渡すことができます。そのルートには追加の引数 {account_type} が必要であることを指定する必要があります。RallyDestination とその SingleAccount オブジェクトを見ると、この引数がすでに accountTypeArg String として使用できるように定義されていることがわかります。

ナビゲーション時にルートとともに引数を渡すには、"route/{argument}" のパターンで合わせて追加する必要があります。この例の場合は次のようになります。"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"。変数をエスケープするには $ 記号を使用します。

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
) {
    SingleAccountScreen()
}

これにより、SingleAccountScreen に移動するアクションがトリガーされたときに確実に accountTypeArg 引数も渡すことができます。渡さないと、ナビゲーションは失敗します。これは、SingleAccountScreen にナビゲートしようとしている他のデスティネーションに義務付けられる署名または契約と考えることができます。

2 番目のステップでは、引数を受け入れる必要があることをこの composable に認識させます。そのためには、arguments パラメータを定義します。composable 関数は引数のリストをデフォルトで受け入れるため、引数を必要な分だけいくつでも定義できます。この例では、accountTypeArg という 1 つの引数のみを追加し、String 型として指定することで安全性を改善します。型を明示的に設定しない場合は、この引数のデフォルト値から推測されます。

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments = listOf(
        navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType }
    )
) {
    SingleAccountScreen()
}

このコードは完全に機能するため、そのまま使用することもできます。ただし、デスティネーション固有の情報はすべて RallyDestinations.kt とそのオブジェクトにあるため、引き続き同じ方法(OverviewAccounts,Bills で行ったのと同じ方法)を使用して、この引数のリストを SingleAccount: に移動しましょう。

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

前の引数を SingleAccount.arguments に置き換え、NavHost の対応する composable に戻しました。こうすることで、NavHost を可能な限りクリーンで判読が容易な状態に保持することにもなります。

composable(
    route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments =  SingleAccount.arguments
) {
    SingleAccountScreen()
}

SingleAccountScreen の引数を使用して完全なルートを定義したところで、次のステップではこの accountTypeArgSingleAccountScreen コンポーザブルに渡して、表示する口座種別を正確に判別できるようにします。SingleAccountScreen の実装を見ると、すでにセットアップされ、accountType パラメータを受け入れられる状態であることがわかります。

fun SingleAccountScreen(
    accountType: String? = UserData.accounts.first().name
) {
   // ...
}

ここまでの内容をまとめます。

  • 引数を求めるルートを、その前のデスティネーションへのシグナルとして定義しました
  • composable が引数を受け入れる必要があることを認識するようにしました

最後のステップでは、なんらかの方法で実際に渡された引数を取得します。

Compose Navigation では、NavHost のコンポーズ可能な関数はいずれも、現在の NavBackStackEntry(バックスタックのエントリにある、現在のルートと渡された引数に関する情報を保持するクラス)にアクセスできます。これを使用して navBackStackEntry から必要な arguments リストを取得し、必要とする正確な引数を検索して取得して、さらにコンポーザブル画面に渡すことができます。

この例では、navBackStackEntry から accountTypeArg をリクエストします。その後、これを SingleAccountScreen'accountType パラメータに渡す必要があります。

引数を指定しない場合は、プレースホルダとして引数のデフォルト値を指定して、このエッジケースをカバーすることによってコードの安全性を改善することもできます。

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

NavHost(...) {
    // ...
    composable(
        route =
          "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
        arguments = SingleAccount.arguments
    ) { navBackStackEntry ->
        // Retrieve the passed argument
        val accountType =
            navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)

        // Pass accountType to SingleAccountScreen
        SingleAccountScreen(accountType)
    }
}

これで、アクセス時に正しい口座種別を表示するために必要な情報が SingleAccountScreen に追加されました。SingleAccountScreen, の実装を確認すると、渡された accountTypeUserData ソースの照合がすでに行われ、対応する口座の詳細情報が取得されています。

最適化のため、もう 1 つ小規模な作業を行いましょう。"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}" ルートも RallyDestinations.kt とその SingleAccount オブジェクト : に移動します。

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val routeWithArgs = "${route}/{${accountTypeArg}}"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

ここでも、対応する NavHost composable: で置き換えます。

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments
) {...}

Accounts と Overview の最初のデスティネーションをセットアップする

ここまでで、SingleAccountScreen ルートと、SingleAccountScreen へのナビゲーションが正常に行われるためにルートが受け入れる必要のある引数を定義しました。次に、同じ accountTypeArg 引数が前のデスティネーション(つまり、移行元の任意のデスティネーション)から渡されることを確認する必要があります。

これには 2 つの側面があります。一つは引数を指定して渡す最初のデスティネーション、もう一つは引数を受け取り、受け取った引数を使用して正しい情報を表示するランディング デスティネーションです。どちらも明示的に定義する必要があります。

たとえば、Accounts デスティネーションで口座種別の [Checking] をタップした場合、対応する SingleAccountScreen を正常に開くには、口座のデスティネーションは「single_account」String ルートに追加するための「Checking」String を引数として渡す必要があります。String ルートは "single_account/Checking" のようになります。

navController.navigateSingleTopTo(...), を使用する場合、渡された引数を含むまったく同じルートを使用します。次のようになります。

navController.navigateSingleTopTo("${SingleAccount.route}/$accountType")

このナビゲーション アクション コールバックを、OverviewScreenAccountsScreenonAccountClick パラメータに渡します。これらのパラメータは、onAccountClick: (String) -> Unit(String は入力)として事前定義されています。つまり、ユーザーが OverviewAccount で特定の口座種別をタップした時点で、対象の口座種別の String がすでに使用可能になっており、ナビゲーション引数として簡単に渡せます。

OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)
// ...

AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)

判読が容易な状態に保持するために、このナビゲーション アクションをプライベート ヘルパーの拡張関数に抽出します。

import androidx.navigation.NavHostController
// ...
OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...

AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

この段階でアプリを実行して各口座種別をクリックすると、対応する SingleAccountScreen に移動し、その口座のデータが表示されます。

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

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

引数を追加するだけでなく、ディープリンクを追加することでも、特定の URL、アクション、MIME タイプをコンポーザブルに関連付けられます。Android では、アプリ内の特定のデスティネーションにユーザーを直接誘導するリンクをディープリンクと呼びます。Navigation Compose は、暗黙的ディープリンクをサポートしています。暗黙的ディープリンクが呼び出される(例: ユーザーがリンクをクリックした)と、Android は対応するデスティネーションでアプリを開くことができます。

このセクションでは、対応する口座種別の SingleAccountScreen コンポーザブルにナビゲートするための新しいディープリンクを追加し、このディープリンクを外部アプリにも公開できるようにします。このコンポーザブルのルートは "single_account/{account_type}" でした。これを、ディープリンク用に軽微な変更を加えて使用します。

外部アプリへのディープリンクの公開は、デフォルトでは有効になっていません。そのため、最初のステップとして、アプリの manifest.xml ファイルに <intent-filter> 要素を追加する必要があります。

まず、アプリの AndroidManifest.xml にディープリンクを追加します。アクション VIEW とカテゴリ BROWSABLE および DEFAULT を使用して、<activity> 内に <intent-filter> を介して新しいインテント フィルタを作成する必要があります。

次に、フィルタ内に data タグを設定し、schemerally - アプリの名前)と hostsingle_account - コンポーザブルへのルート)を追加して、正確なディープリンクを定義する必要があります。これにより、ディープリンク URL として rally://single_account を取得できます。

AndroidManifestaccount_type 引数を宣言する必要はありません。これは、後ほどコンポーズ可能な関数 NavHost 内に追加されます。

<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="single_account" />
    </intent-filter>
</activity>

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

コンポーザブル SingleAccountScreen は、すでに引数を受け取るように設定されていますが、ディープリンクがトリガーされたときにこのデスティネーションを起動するには、新しく作成されたディープリンクを受け入れる必要もあります。

SingleAccountScreen のコンポーズ可能な関数内に、パラメータ deepLinks を 1 つ追加します。これは arguments, と同様、navDeepLink のリストを受け入れるため、同じデスティネーションに誘導する複数のディープリンクを定義できるようになっています。uriPattern を渡し、マニフェストの intent-filter で定義されているもの(rally://singleaccount)に一致させます。今回は accountTypeArg 引数も追加します。

import androidx.navigation.navDeepLink
// ...

composable(
    route = SingleAccount.routeWithArgs,
    // ...
    deepLinks = listOf(navDeepLink {
        uriPattern = "rally://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
    })
)

次のステップはもうおわかりでしょう、このリストを次の場所に移動します。RallyDestinations SingleAccount:

object SingleAccount : RallyDestination {
    // ...
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
    val deepLinks = listOf(
       navDeepLink { uriPattern = "rally://$route/{$accountTypeArg}"}
    )
}

ここでも、対応する NavHost コンポーザブルで置き換えます。

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments,
    deepLinks = SingleAccount.deepLinks
) {...}

これで、アプリと SingleAccountScreen がディープリンクを処理する準備が整いました。正しく動作するかどうかをテストするには、接続されているエミュレータまたはデバイスに Rally を新規インストールし、コマンドラインを開いて次のコマンドを実行します。これにより、ディープリンクの起動をシミュレートできます。

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

これで、ユーザーは「Checking」口座に直接誘導されるようになります。その他の口座種別についても、正しく動作するかを確認できます。

9. NavHost を RallyNavHost に抽出する

これで NavHost が完成しました。ただし、これをテスト可能にし、RallyActivity をクリーンな状態に保持するために、現在の NavHost と、そのヘルパー関数(navigateToSingleAccount など)を RallyApp コンポーザブルから独自のコンポーズ可能な関数に抽出できます。名前は RallyNavHost とします。

RallyApp は、navController と直接連携する唯一のコンポーザブルです。前述のように、他のすべてのネストされたコンポーザブル画面は、navController 自体ではなく、ナビゲーション コールバックのみを取得します。

したがって、新しい RallyNavHost は、navControllermodifierRallyApp のパラメータとして受け入れます。

@Composable
fun RallyNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = modifier
    ) {
        composable(route = Overview.route) {
            OverviewScreen(
                onClickSeeAllAccounts = {
                    navController.navigateSingleTopTo(Accounts.route)
                },
                onClickSeeAllBills = {
                    navController.navigateSingleTopTo(Bills.route)
                },
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Accounts.route) {
            AccountsScreen(
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Bills.route) {
            BillsScreen()
        }
        composable(
            route = SingleAccount.routeWithArgs,
            arguments = SingleAccount.arguments,
            deepLinks = SingleAccount.deepLinks
        ) { navBackStackEntry ->
            val accountType =
              navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
            SingleAccountScreen(accountType)
        }
    }
}

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

新しい RallyNavHostRallyApp に追加してアプリを再実行し、すべてが以前と同様に動作することを確認しましょう。

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

10. Compose Navigation をテストする

この Codelab では、当初から navController を直接コンポーザブルに渡すのではなく(高レベルアプリ以外)、ナビゲーション コールバックをパラメータとして渡すようにしています。これにより、テストで navController のインスタンスが不要になるため、すべてのコンポーザブルを個別にテストできます。

RallyNavHost とコンポーザブルに渡されたナビゲーション アクションをテストして、Compose Navigation メカニズム全体がアプリ内で意図したとおりに機能するかどうかを常にテストするようにします。これが、このセクションの主な目標です。個別のコンポーズ可能な関数を単独でテストするには、Jetpack Compose でのテストの Codelab をご確認ください。

テストを開始するには、まず必要なテスト用依存関係を追加する必要があるため、app/build.gradle にあるアプリのビルドファイルに戻ります。テスト用の依存関係のセクションで、navigation-testing の依存関係を追加します。

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

NavigationTest クラスを準備する

RallyNavHost は、Activity 自体とは別にテストできます。

このテストは引き続き Android デバイスで実行されるため、テスト ディレクトリ /app/src/androidTest/java/com/example/compose/rally を作成してから、新しいテストファイルのテストクラスを作成して NavigationTest という名前を付ける必要があります。

最初のステップとして、Compose テスト API を使用するため、また Compose を使用してコンポーザブルとアプリケーションのテストおよび制御を行うために、Compose テストルールを追加します。

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

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

}

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

公開されている rallyNavHost テスト関数を作成し、@Test アノテーションを付加します。この関数では、まずテストする Compose コンテンツを設定する必要があります。これを行うには、composeTestRulesetContent を使用します。コンポーザブルのパラメータを本文として受け取り、通常の本番環境アプリの場合と同様に、Compose コードを記述し、テスト環境でコンポーザブルを追加できるようにします。

setContent, 内で、現在のテスト サブジェクト RallyNavHost をセットアップし、新しい navController インスタンスのインスタンスを渡すことができます。ナビゲーション テスト アーティファクトには、使用しやすい TestNavHostController が用意されています。このステップではこれを追加しましょう。

import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Assert.fail
import org.junit.Test
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost() {
        composeTestRule.setContent {
            // Creates a TestNavHostController
            navController =
                TestNavHostController(LocalContext.current)
            // Sets a ComposeNavigator to the navController so it can navigate through composables
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }
        fail()
    }
}

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

正しい画面コンポーザブルが表示されていることを確認するには、contentDescription を使用して、表示されていることをアサートします。この Codelab では、Accounts と Overview のデスティネーション用にすでに contentDescription が設定されているため、それらをテスト検証に使用できます。

最初の確認として、RallyNavHost の初回初期化時に、[Overview] 画面が最初のデスティネーションとして表示されることを確認します。確認内容がわかりやすいよう、テストの名前を変更し、rallyNavHost_verifyOverviewStartDestination としましょう。これを行うには、fail() 呼び出しを次のように置き換えます。

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost_verifyOverviewStartDestination() {
        composeTestRule.setContent {
            navController =
                TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }

        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

テストを再実行し、合格することを確認します。

以降の各テストでも RallyNavHost を同じ方法でセットアップする必要があるため、アノテーションが付加された @Before 関数に初期化したものを抽出します。こうすることで、不要な繰り返しを回避し、テストをより簡潔にできます。

import org.junit.Before
// ...

class NavigationTest {

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

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

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

ナビゲーションの実装は複数の方法でテストできます。UI 要素をクリックして表示されたデスティネーションを検証する方法や、想定されるルートと現在のルートを比較する方法があります。

UI のクリックと画面の contentDescription によるテスト

具体的なアプリの実装をテストする場合は、UI をクリックすることをおすすめします。次のテキストでは、[Overview] 画面が表示されている状態で [Accounts] サブセクションにある [SEE ALL] ボタンをクリックすると、Accounts デスティネーションに移動することを確認します。

5a9e82acf7efdd5b.png

OverviewScreenCard コンポーザブルのこの特定のボタンで設定された contentDescription を再度使用して、performClick() によるクリックをシミュレートし、Accounts デスティネーションが表示されることを確認します。

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

@Test
fun rallyNavHost_clickAllAccount_navigatesToAccounts() {
    composeTestRule
        .onNodeWithContentDescription("All Accounts")
        .performClick()

    composeTestRule
        .onNodeWithContentDescription("Accounts Screen")
        .assertIsDisplayed()
}

このパターンで、アプリの残りのクリック ナビゲーション アクションをすべてテストできます。

UI のクリックとルートの比較によるテスト

navController を使用して、現在の String ルートと想定されるルートを比較することで、アサーションを確認することもできます。これを行うには、前のセクションと同様に UI をクリックし、navController.currentBackStackEntry?.destination?.route を使用して現在のルートと想定されるルートを比較します。

追加のステップとして、まず [Overview] 画面で [Bills] サブセクションまでスクロールする必要があります。そうしないと、contentDescription「All Bills」を含むノードが見つからないため、テストは失敗します。

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

@Test
fun rallyNavHost_clickAllBills_navigateToBills() {
    composeTestRule.onNodeWithContentDescription("All Bills")
        .performScrollTo()
        .performClick()

    val route = navController.currentBackStackEntry?.destination?.route
    assertEquals(route, "bills")
}

これらのパターンに沿って、追加のナビゲーション ルート、デスティネーション、クリック アクションを網羅すれば、テストクラスの完成です。一連のテスト全体を実行し、すべて合格していることを確認します。

11. 完了

これで、この Codelab は終了です。解答コードはこちらで確認できます。ご自身のものと比較してください。

Rally アプリに Jetpack Compose ナビゲーションを追加し、重要なコンセプトを理解しました。コンポーザブル デスティネーションのナビゲーション グラフのセットアップ、ナビゲーションのルートとアクションの定義、引数によるルートへの追加情報の受け渡し、ディープリンクの設定、ナビゲーションのテストを行う方法を学びました。

ボトム ナビゲーション バーの統合、マルチモジュール ナビゲーション、ネストされたグラフなど、その他のトピックと情報についてさらに知りたい方は、最新の Android GitHub リポジトリで実装例を確認できます。

次のステップ

以下の資料を確認して、Jetpack Compose の学習パスウェイを引き続き進めてください。

Jetpack Navigation についてのその他のリソース:

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