1. はじめに
最終更新日: 2022 年 7 月 25 日
必要なもの
- 最新の Android Studio
- Kotlin と後置ラムダに関する知識
- ナビゲーションとその用語(バックスタックなど)に関する基本的な知識
- Compose に関する基礎知識
- この Codelab の前に Jetpack Compose の基本の Codelab を受講することを検討してください。
- Compose の状態管理に関する基本的な知識
- この Codelab の前に Jetpack Compose での状態に関する Codelab を受講することを検討してください。
Compose を使用したナビゲーション
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 つのメイン画面があります。
OverviewScreen
- すべての金融取引とアラートの概要AccountsScreen
- 既存の口座の分析情報BillsScreen
- 予定されている支出
画面最上部の Rally は、カスタム タブバー コンポーザブル(RallyTabRow
)を使用して、これら 3 つの画面をナビゲートしています。各アイコンをタップすると、現在の選択が展開され、対応する画面に移動します。
これらのコンポーザブル画面に移動する場合は、特定の位置で各画面にアクセスする必要があることから、それらの画面をナビゲーション デスティネーションと考えることもできます。これらのデスティネーションは、RallyDestinations.kt
ファイルで事前定義されています。
このファイル内で、オブジェクトとして定義されている 3 つの主要なデスティネーション(Overview, Accounts
および Bills
)と、後ほどアプリに追加する SingleAccount
を確認できます。各オブジェクトは RallyDestination
インターフェースから拡張され、ナビゲーションに必要な各デスティネーションに関する必要な情報を含みます。
- トップバーの
icon
- String
route
(Compose Navigation で、対象のデスティネーションにアクセスするためのパスとして必須) - このデスティネーションのコンポーザブル全体を表す
screen
アプリを実行すると、現在トップバーを使用しているデスティネーション間を移動できますが、アプリは実際には Compose Navigation を使用していません。その代わりに、現在のナビゲーション メカニズムでは、コンポーザブルを手動で切り替え、再コンポジションをトリガーして新しいコンテンツを表示するように設定されています。したがって、この Codelab の目標は、Compose Navigation への移行とその実装を完了することです。
4. Compose Navigation に移行する
Jetpack Compose への基本的な移行の手順は次のとおりです。
- 最新の Compose Navigation の依存関係を追加する
NavController
をセットアップするNavHost
を追加してナビゲーション グラフを作成する- アプリの異なるデスティネーション間をナビゲートするためのルートを準備する
- 現在のナビゲーション メカニズムを 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
で定義されます。各デスティネーションに icon
、route
、screen
が定義されていることを説明しました。
次のステップでは、これらのデスティネーションをナビゲーション グラフに追加し、Overview
をアプリ起動時の最初のデスティネーションに設定します。
Compose 内で Navigation を使用する場合、ナビゲーション グラフ内の各コンポーザブルのデスティネーションにはルートが関連付けられます。ルートは、コンポーザブルへのパスを定義する String として表現され、navController
が正しい場所に到達できるようにします。これは特定のデスティネーションにつながる暗黙的なディープリンクと考えることができます。デスティネーションごとに一意のルートを指定する必要があります。
これを実現するには、各 RallyDestination
オブジェクトの route
プロパティを使用します。たとえば、Overview.route
は Overview
画面コンポーザブルに移動するルートです。
ナビゲーション グラフを含む NavHost コンポーザブルを呼び出す
次のステップでは、NavHost
を追加して、ナビゲーション グラフを作成します。
Navigation の主な要素は、NavController
、NavGraph
、NavHost
の 3 つです。NavController
は常に 1 つの NavHost
コンポーザブルに関連付けられます。NavHost
はコンテナとして機能し、グラフの現在のデスティネーションを表示します。コンポーザブル間を移動すると、NavHost
のコンテンツは自動的に再コンポーズされます。また、NavHost は NavController
をナビゲーション グラフ(NavGraph
)にリンクします。ナビゲーション グラフは、コンポーザブルのデスティネーションをマッピングして、それらのデスティネーション間を移動できるようにします。NavHost は基本的に、取得可能なデスティネーションのコレクションです。
RallyActivity.kt
の RallyApp
コンポーザブルに戻ります。画面を手動で切り替えるために現在の画面のコンテンツを格納する 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 をナビゲーションと統合する
このステップでは、RallyTabRow
を navController
とナビゲーション グラフに接続して、正しいデスティネーションに移動できるようにします。
そのためには、新しい navController
を使用して、RallyTabRow
の onTabSelected
コールバックの適切なナビゲーション アクションを定義する必要があります。このコールバックは、特定のタブアイコンが選択されたときに行われる動作を定義し、navController.navigate(route)
.
を介してナビゲーション アクションを実行します。
このガイダンスに沿って、RallyActivity
で RallyTabRow
コンポーザブルとそのコールバック パラメータ 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 つあります。
- 特定の行の同じタブを再度タップすると、同じデスティネーションの複数のコピーが起動する
- タブの UI が、表示される正しいデスティネーションと一致していない - つまり、選択したタブの展開と折りたたみが意図したとおりに機能していない
両方とも修正しましょう。
デスティネーションのコピーを 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
で使用できるフラグがあります。RallyTabRow
は BottomNavigation
と同様に動作するため、デスティネーション間を移動する際に、デスティネーションの状態を保存および復元するかどうかも検討する必要があります。たとえば、[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?.destination
は NavDestination
.
を返します。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
とスクリーン オブジェクトはナビゲーション固有の情報(icon
や route
など)のみを保持し、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] ボタンはクリック可能ですが、実際には移動できません。このステップの目標は、これらのクリック イベントのナビゲーションを有効にすることです。
OverviewScreen
コンポーザブルは、複数の関数をコールバックとして受け入れて、クリック イベントとして設定できます。この例では、AccountsScreen
または BillsScreen
に移動するナビゲーション アクションであることが必要です。正しいデスティネーションにナビゲートできるよう、これらのナビゲーション コールバックを onClickSeeAllAccounts
と onClickSeeAllBills
に渡しましょう。
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」などのさまざまな種類の口座の一覧が表示されています。
ただし、これらの口座種別をクリックしても何も起こりません。この問題を修正しましょう。各口座種別をタップした際に、口座の詳細情報を含む新しい画面が表示されるようにします。そのためには、どの口座種別がクリックされたかを 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
とそのオブジェクトにあるため、引き続き同じ方法(Overview
、Accounts,
、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
の引数を使用して完全なルートを定義したところで、次のステップではこの accountTypeArg
を SingleAccountScreen
コンポーザブルに渡して、表示する口座種別を正確に判別できるようにします。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,
の実装を確認すると、渡された accountType
と UserData
ソースの照合がすでに行われ、対応する口座の詳細情報が取得されています。
最適化のため、もう 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")
このナビゲーション アクション コールバックを、OverviewScreen
と AccountsScreen
の onAccountClick
パラメータに渡します。これらのパラメータは、onAccountClick: (String) -> Unit
(String は入力)として事前定義されています。つまり、ユーザーが Overview
と Account
で特定の口座種別をタップした時点で、対象の口座種別の 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
に移動し、その口座のデータが表示されます。
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
タグを設定し、scheme
(rally
- アプリの名前)と host
(single_account
- コンポーザブルへのルート)を追加して、正確なディープリンクを定義する必要があります。これにより、ディープリンク URL として rally://single_account
を取得できます。
AndroidManifest
で account_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
) {...}
adb を使用してディープリンクをテストする
これで、アプリと 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
は、navController
と modifier
を RallyApp
のパラメータとして受け入れます。
@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")
}
新しい RallyNavHost
を RallyApp
に追加してアプリを再実行し、すべてが以前と同様に動作することを確認しましょう。
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 コンテンツを設定する必要があります。これを行うには、composeTestRule
の setContent
を使用します。コンポーザブルのパラメータを本文として受け取り、通常の本番環境アプリの場合と同様に、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 デスティネーションに移動することを確認します。
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 についてのその他のリソース: