Compose を使用したナビゲーション

Navigation コンポーネントJetpack Compose アプリをサポートしています。Navigation コンポーネントのインフラストラクチャと機能を活用しながら、コンポーザブル間を移動することができます。

設定

Compose をサポートするには、アプリ モジュールの build.gradle ファイルで次の依存関係を使用します。

Groovy

dependencies {
    def nav_version = "2.5.3"

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

Kotlin

dependencies {
    def nav_version = "2.5.3"

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

はじめに

NavController は Navigation コンポーネントの中心的な API です。この API はステートフルであり、アプリ内の画面を構成するコンポーザブルのバックスタックと各画面の状態を追跡します。

NavController を作成するには、コンポーザブルで rememberNavController() メソッドを使用します。

val navController = rememberNavController()

NavController は、これを参照する必要があるすべてのコンポーザブルがアクセスできるコンポーザブル階層内の場所に作成する必要があります。これは状態ホイスティングの原則に従っており、NavController とその currentBackStackEntryAsState() を介して提供される状態を、画面の外部でコンポーザブルを更新する際の信頼できる情報として使用できるようになります。この機能の例については、下部のナビゲーション バーとの統合をご覧ください。

NavHost の作成

NavController は 1 つの NavHost コンポーザブルに関連付ける必要があります。NavHostNavController にナビゲーション グラフを関連付けます。ナビゲーション グラフではコンポーザブルのデスティネーション(目的地)が指定されており、それらのデスティネーション間を移動できるようになります。コンポーザブル間を移動すると、NavHost のコンテンツは自動的に再コンポーズされます。ナビゲーション グラフ内の各コンポーザブルのデスティネーションには「ルート」が関連付けられています。

NavHost を作成するには、rememberNavController() を使って以前に作成された NavController と、グラフの開始デスティネーションのルートが必要です。NavHost の作成では、Navigation Kotlin DSL のラムダ構文を使用して、ナビゲーション グラフを作成します。composable() メソッドを使用して、ナビゲーション構造に追加できます。このメソッドでは、デスティネーションにリンクするルートとコンポーザブルを指定する必要があります。

NavHost(navController = navController, startDestination = "profile") {
    composable("profile") { Profile(/*...*/) }
    composable("friendslist") { FriendsList(/*...*/) }
    /*...*/
}

ナビゲーション グラフ内のコンポーザブルのデスティネーションに移動するには、navigate メソッドを使用する必要があります。navigate は、デスティネーションのルートを表す単一の String パラメータを取ります。ナビゲーション グラフ内でコンポーザブルから移動するには、navigate を呼び出します。

navController.navigate("friendslist")

デフォルトでは、navigate は新しいデスティネーションをバックスタックに追加します。navigate の動作を変更するには、追加のナビゲーション オプションを navigate() 呼び出しにアタッチします。

// Pop everything up to the "home" destination off the back stack before
// navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home")
}

// Pop everything up to and including the "home" destination off
// the back stack before navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home") { inclusive = true }
}

// Navigate to the "search” destination only if we’re not already on
// the "search" destination, avoiding multiple copies on the top of the
// back stack
navController.navigate("search") {
    launchSingleTop = true
}

その他のユースケースについては、popUpTo のガイドをご覧ください。

NavControllernavigate 関数は、NavController の内部状態を変更します。信頼できる唯一の情報源の原則に可能な限り従うには、NavController インスタンスをホイスティングするコンポーズ可能な関数または状態ホルダー、および NavController をパラメータとして受け取るコンポーズ可能な関数のみがナビゲーション呼び出しを行うようにする必要があります。UI 階層の下位にある他のコンポーズ可能な関数からトリガーされたナビゲーション イベントは、関数を適切に使用する呼び出し元に公開する必要があります。

次の例は、コンポーズ可能な関数 MyAppNavHost を、NavController インスタンスの信頼できる唯一の情報源として示しています。ProfileScreen はイベントを、ユーザーがボタンをタップしたときに呼び出される関数として公開します。MyAppNavHost は、アプリ内の異なる画面へのナビゲーションを所有し、ProfileScreen を呼び出すときに適切なデスティネーションに対してナビゲーション呼び出しを行います。

@Composable
fun MyAppNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController(),
    startDestination: String = "profile"
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = startDestination
    ) {
        composable("profile") {
            ProfileScreen(
                onNavigateToFriends = { navController.navigate("friendsList") },
                /*...*/
            )
        }
        composable("friendslist") { FriendsListScreen(/*...*/) }
    }
}

@Composable
fun ProfileScreen(
    onNavigateToFriends: () -> Unit,
    /*...*/
) {
    /*...*/
    Button(onClick = onNavigateToFriends) {
        Text(text = "See friends list")
    }
}

再コンポーズのたびに navigate() が呼び出されるのを避けるため、navigate() は、コンポーザブル自体の一部としてではなく、常にコールバックの一部として呼び出してください。

アプリ内で特定のロジックを処理する方法を知っている呼び出し元に、コンポーズ可能な関数からのイベントを公開することは、状態をホイスティングする場合の Compose でのおすすめの方法です。

イベントを個別のラムダ パラメータとして公開することで、関数シグネチャがオーバーロードされる可能性がありますが、コンポーズ可能な関数で行う処理が最大限に可視化されます。その関数の機能が一目でわかります。

関数宣言でパラメータの数を減らせるその他の方法は、最初は書くのが楽かもしれませんが、長期的には欠点がいくつかあります。たとえば、すべてのイベントを 1 か所に一元化する ProfileScreenEvents のようなラッパークラスを作成する方法です。これを行うと、関数の定義の際にコンポーザブルが行う処理の可視性が低下し、さらに別のクラスやメソッドがプロジェクトに追加されます。そのため、コンポーズ可能な関数を呼び出すたびに、そのクラスのインスタンスを作成して覚えておくことが必要になります。また、そのラッパークラスをできる限り再利用する場合、このパターンでは、そのクラスのインスタンスを UI 階層の下位に渡すことになりがちです。これは、必要なものだけをコンポーザブルに渡すというおすすめの方法に反します。

Navigation Compose では、コンポーザブルのデスティネーション間で引数を渡すこともできます。これを行うには、基本のナビゲーション ライブラリを使用する場合にディープリンクに引数を追加する方法と同様に、引数のプレースホルダをルートに追加する必要があります。

NavHost(startDestination = "profile/{userId}") {
    ...
    composable("profile/{userId}") {...}
}

デフォルトでは、すべての引数が文字列として解析されます。composable()arguments パラメータは NamedNavArgument のリストを受け取ります。navArgument メソッドを使用して NamedNavArgument をすばやく作成してから、その正確な type を指定できます。

NavHost(startDestination = "profile/{userId}") {
    ...
    composable(
        "profile/{userId}",
        arguments = listOf(navArgument("userId") { type = NavType.StringType })
    ) {...}
}

composable() 関数のラムダで使用可能な NavBackStackEntry から引数を抽出する必要があります。

composable("profile/{userId}") { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

デスティネーションに引数を渡すには、navigate を呼び出すときにルートに引数を追加する必要があります。

navController.navigate("profile/user1234")

サポートされる型の一覧については、デスティネーション間でデータを渡すをご覧ください。

移動時の複雑なデータの取得

移動時には複雑なデータ オブジェクトを渡すのではなく、ナビゲーション アクションの実行時に引数として必要最低限の情報(一意の識別子やその他の形式の ID など)を渡すことを強くおすすめします。

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

複雑なオブジェクトは信頼できる単一の情報源(データレイヤなど)にデータとして保存する必要があります。移動後にデスティネーションに到達したら、渡された ID を使用して、信頼できる単一の情報源から必要な情報を読み込むことができます。データレイヤへのアクセスを担う ViewModel で引数を取得するには、ViewModel’s SavedStateHandle を使用できます。

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

    private val userId: String = checkNotNull(savedStateHandle["userId"])

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

// …

}

この手法により、設定の変更時のデータ損失や、対象のオブジェクトの更新時や変更時の不整合を防ぐことができます。

複雑なデータを引数として渡すのを避けるべき理由についての詳しい説明と、サポートされる引数タイプのリストについては、デスティネーション間でデータを渡すをご覧ください。

省略可能な引数の追加

Navigation Compose は、省略可能なナビゲーション引数もサポートしています。省略可能な引数は、次の 2 つの点で必須の引数とは異なります。

  • クエリ パラメータの構文("?argName={argName}")を使用して指定する必要があります。
  • defaultValue を設定するか、nullability = true(デフォルト値を暗黙的に null に設定)を指定する必要があります。

つまり、省略可能なすべての引数をリストとして composable() 関数に明示的に追加する必要があります。

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

デスティネーションに渡される引数がない場合でも、代わりに defaultValue の「user1234」が使用されます。

ルートを通じて引数を処理するという構造のため、コンポーザブルは Navigation から完全に独立しており、テストがかなり容易になります。

Navigation Compose は、composable() 関数の一部として定義できる暗黙的なディープリンクもサポートしています。その deepLinks パラメータは、navDeepLink メソッドを使用してすばやく作成できる NavDeepLink のリストを受け取ります。

val uri = "https://www.example.com"

composable(
    "profile?id={id}",
    deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("id"))
}

ディープリンクを使用すると、特定の URL、アクション、MIME タイプをコンポーザブルに関連付けることができます。デフォルトでは、これらのディープリンクは外部アプリには公開されません。これらのディープリンクを外部で使用できるようにするには、適切な <intent-filter> 要素をアプリの manifest.xml ファイルに追加する必要があります。上記のディープリンクを有効にするには、マニフェストの <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/$id".toUri(),
    context,
    MyActivity::class.java
)

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

この deepLinkPendingIntent を他の PendingIntent と同様に使用して、ディープリンクのデスティネーションでアプリを開くことができます。

ネスト ナビゲーション

デスティネーションをネストグラフにグループ化して、アプリの UI 内の特定のフローをモジュール化できます。その例として、自己完結型のログインフローがあります。

ネストグラフはデスティネーションをカプセル化します。ルートグラフと同様、ネストグラフには、ルートによって開始デスティネーションとして識別されるデスティネーションが必要です。これは、ネストグラフに関連付けられたルートに移動するときに誘導されるデスティネーションです。

ネストグラフを NavHost に追加するには、navigation 拡張関数を使用します。

NavHost(navController, startDestination = "home") {
    ...
    // Navigating to the graph via its route ('login') automatically
    // navigates to the graph's start destination - 'username'
    // therefore encapsulating the graph's internal routing logic
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
    ...
}

グラフのサイズが大きくなる場合は、ナビゲーション グラフを複数のメソッドに分割することを強くおすすめします。これにより、複数のモジュールがそれぞれ独自のナビゲーション グラフを提供できるようになります。

fun NavGraphBuilder.loginGraph(navController: NavController) {
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
}

メソッドを NavGraphBuilder の拡張メソッドにすると、ビルド済みの navigationcomposabledialog 拡張メソッドと併せて使用できます。

NavHost(navController, startDestination = "home") {
    ...
    loginGraph(navController)
    ...
}

下部のナビゲーション バーとの統合

コンポーザブルの階層内の上位のレベルで NavController を定義することで、Navigation を他のコンポーネント(ボトム ナビゲーション コンポーネントなど)と接続できます。これにより、下部のバーのアイコンを選択して移動できるようになります。

BottomNavigation コンポーネントと BottomNavigationItem コンポーネントを使用するには、androidx.compose.material の依存関係を Android アプリに追加します。

Groovy

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

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.4.2"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

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

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.4.2"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

下部のナビゲーション バーのアイテムをナビゲーション グラフ内のルートにリンクするには、デスティネーションのルートと文字列のリソース ID を含むシールクラス(下記の Screen など)を定義することをおすすめします。

sealed class Screen(val route: String, @StringRes val resourceId: Int) {
    object Profile : Screen("profile", R.string.profile)
    object FriendsList : Screen("friendslist", R.string.friends_list)
}

次に、そのアイテムを BottomNavigationItem が使用可能なリスト内に配置します。

val items = listOf(
   Screen.Profile,
   Screen.FriendsList,
)

BottomNavigation コンポーザブルで、currentBackStackEntryAsState() 関数を使用して現在の NavBackStackEntry を取得します。このエントリにより、現在の NavDestination にアクセスできるようになります。NavDestination hierarchy を通じて、アイテムのルートを現在のデスティネーションおよびその親デスティネーション(ネストされたナビゲーションを使用しているケースに対応するため)のルートと比較することで、各 BottomNavigationItem の選択された状態を判定できます。

アイテムをタップするとそのアイテムに移動するように、onClick ラムダを navigate の呼び出しに接続するためにもアイテムのルートが使用されます。saveStaterestoreState のフラグを使用すると、ボトム ナビゲーション アイテムを切り替えるときに、そのアイテムの状態とバックスタックが正しく保存され、復元されます。

val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      items.forEach { screen ->
        BottomNavigationItem(
          icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
          label = { Text(stringResource(screen.resourceId)) },
          selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
          onClick = {
            navController.navigate(screen.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 = Screen.Profile.route, Modifier.padding(innerPadding)) {
    composable(Screen.Profile.route) { Profile(navController) }
    composable(Screen.FriendsList.route) { FriendsList(navController) }
  }
}

ここでは、NavController.currentBackStackEntryAsState() メソッドを使用して NavHost 関数から navController の状態をホイスティングし、BottomNavigation コンポーネントと共有しています。つまり、BottomNavigation は自動的に最新の状態を保持します。

Navigation Compose での型安全性

このページのコードはタイプセーフではありません。存在しないルートまたは誤った引数を使用して navigate() 関数を呼び出すことも可能です。ただし、実行時にタイプセーフになるように Navigation コードを構成することは可能です。これにより次のことが確保され、クラッシュが回避されます。

  • デスティネーションまたはナビゲーション グラフに移動するときに指定する引数は適切な型であり、必要な引数がすべて存在している。
  • SavedStateHandle から取得する引数は正しい型である。

詳しくは、Navigation の型安全性に関するドキュメントをご覧ください。

相互運用性

Compose で Navigation コンポーネントを使用するには、次の 2 つの方法があります。

  • フラグメントに Navigation コンポーネントを使用して、ナビゲーション グラフを定義します。
  • Compose のデスティネーションを使用して、Compose 内の NavHost でナビゲーション グラフを定義します。これは、ナビゲーション グラフ内のすべての画面がコンポーザブルである場合にのみ可能です。

したがって、Compose アプリと View アプリを組み合わせる場合は、フラグメント ベースの Navigation コンポーネントを使用することをおすすめします。フラグメントは、View ベースの画面、Compose の画面、および View と Compose の両方を使用する画面を保持します。各フラグメントのコンテンツが Compose に組み込まれたら、次のステップでは、これらすべての画面を Navigation Compose に関連付けて、すべてのフラグメントを削除します。

Compose コード内のデスティネーションを変更するには、階層内の任意のコンポーザブルに渡してそのコンポーザブルからトリガーできるイベントを公開します。

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

フラグメント内で、NavController を見つけてデスティネーションに移動することにより、Compose とフラグメント ベースの Navigation コンポーネントを橋渡しします。

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

または、NavController を Compose 階層の上から下に渡すこともできます。ただし、シンプルな関数を公開すると、再利用とテストがより簡単になります。

テスト

Navigation のコードをコンポーザブルのデスティネーションから分離して、NavHost コンポーザブルとは別に、各コンポーザブルを個別にテストできるようにすることを強くおすすめします。

つまり、navController任意のコンポーザブルに直接渡すのではなく、ナビゲーション コールバックをパラメータとして渡す必要があります。これにより、テストで navController のインスタンスが不要になるため、すべてのコンポーザブルを個別にテストできます。

composable ラムダによって一定のレベルの間接性が提供されるため、Navigation のコードをコンポーザブル自体と分離できるようになります。このことは次の 2 つの方向に機能します。

  • 解析された引数のみをコンポーザブルに渡します。
  • NavController 自体ではなく、移動するコンポーザブルによってトリガーされるラムダを渡します。

たとえば、userId を入力として取り、ユーザーに友だちのプロフィール ページへの移動を許可する Profile コンポーザブルの場合、次のようなシグネチャになります。

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

このように、Profile コンポーザブルは Navigation とは独立して動作するため、独立してテストできます。composable ラムダは、Navigation API とコンポーザブルの間のギャップを埋めるために必要な最小限のロジックをカプセル化することになります。

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(backStackEntry.arguments?.getString("userId")) { friendUserId ->
        navController.navigate("profile?userId=$friendUserId")
    }
}

NavHost、コンポーザブルに渡されるナビゲーション アクション、個々の画面コンポーザブルをテストすることにより、アプリ ナビゲーションの要件に対応するテストを作成することをおすすめします。

NavHost のテスト

NavHost のテストを開始するには、次の navigation-testing の依存関係を追加します。

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

NavHost のテスト対象を設定し、navController インスタンスのインスタンスをその対象に渡します。このために、Navigation テスト アーティファクトには TestNavHostController が用意されています。アプリの開始デスティネーションと 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 要素をクリックして表示されたデスティネーションを検証する方法や、想定されるルートと現在のルートを比較する方法があります。

具体的なアプリの実装をテストする場合は、UI をクリックすることをおすすめします。このテスト方法と、個々のコンポーズ可能な関数を個別にテストする方法については、Jetpack Compose でのテスト Codelab をご覧ください。

navControllernavControllercurrentBackStackEntry)を使用して、現在の String ルートと想定されるルートを比較することで、アサーションを確認することもできます。

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

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

Compose のテストの基本の詳細については、Compose テストのドキュメントと Jetpack Compose でのテスト Codelab をご覧ください。ナビゲーション コードの高度なテストについて詳しくは、ナビゲーションをテストするのガイドをご覧ください。

詳細

Jetpack Navigation の詳細については、Navigation コンポーネント スタートガイドを参照するか、Jetpack Compose ナビゲーション Codelab をご覧ください。

さまざまな画面サイズ、向き、フォーム ファクタに適応するようにアプリのナビゲーションを設計する方法については、レスポンシブ UI のナビゲーションをご覧ください。

モジュール化されたアプリでの Navigation Compose のより高度な実装(ネストされたグラフや下部のナビゲーション バーの統合などの概念)について詳しくは、Now in Android リポジトリをご覧ください。

サンプル