📊

[Jetpack Compose] NavigationBar と Nested Navigation

2023/08/07に公開

はじめに

Compose を使用したナビゲーション | Jetpack Compose | Android Developers ページにて、 NavigationBarItem の selected の判定のサンプルコードは以下のようになっています。

val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->

  NavigationBarItem(
    // ...
    // selected の判定
    selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
  )
}

で、とりあえずこう書いて置けばいい感じに動くか、というとそうでもなく、この方法で各タブのトップページから詳細ページなどに遷移した場合に選択状態を継続させるには、ナビゲーションをネストさせる必要があります。

この記事では navigation に関わる前提知識と、 NavigationBar と Nested navigation を連携する実装例についてまとめます。

前提知識

  • NavDestination: 遷移先
  • NavGraph: NavDestination の集合
  • NavController: 遷移を管理し、遷移を実行するインターフェイスを提供するクラス
  • NavHost: 画面遷移が起きた際に、実際に画面の切り替えを行う領域を提供するコンポーザブル関数。ルートの NavGraph を作成し、与えられた NavController と作成したルート NavGraph を関連付ける。

Nested navigation

ルートの NavGraph には子ノードとして NavDestination だけでなく、 NavGraph をネストして追加することもでき、それにより階層構造や画面のグループを表現することができます。

具体的なコードでは navigation 拡張関数を利用します。

NavHost(
    navController = navController,
    startDestination = "BooksGroup"
) {
    // ネストした NavGraph の追加
    navigation(startDestination = "Books", route = "BooksGroup") {
        composable("Books") {
            BookListScreen()
        }
        composable(
            "Books/{bookId}",
            arguments = listOf(navArgument("bookId") { type = NavType.IntType })
        ) { backStackEntry ->
            val bookId = backStackEntry.arguments!!.getInt("bookId")
            BookDetailScreen(bookId)
        }
    }
    composable("MyPage") {
        MyPage()
    }

この例では以下の様な NavGraph が構成されることになります。

+ Root
  + BooksGroup
    + Books
    + Books/{bookId}
  + MyPage

Nested NavGraph の注意点としては、まず route 名は全体で一意になっている必要があります。また公式ドキュメントには「ネストグラフはデスティネーションをカプセル化します」とありますが、 おそらく NavGraphBuilder のスコープが分かれるというだけで、親子関係にない画面からでも問題なく遷移できます( 上記の例では MyPage から Books/{bookId} への遷移など )。

振り返って最初のコードの意味

NavDestination.hierarchy はその NavDestination からルートまで親をたどったシークエンスです。前節の例でいえば、 Books/{bookId} で hierarchy を参照すると

Destination (route=books/{bookId})
> NavGraph (route=BooksGroup)
> NavGraph (root)

のシークエンスが取得できます。つまり、ドキュメントのコード例では「あるタブページから遷移できる下位の画面はそのタブの NavGraph に所属させる」ことを前提としていることが分かります。

当然、判定条件を変えれば(独自の判定ロジックを組めば)ネストさせる必要はありませんし、タブから遷移したページでは必ずそのタブが選択状態であるべきというわけでもないですが(要件による)、デファクトスタンダード的には、上記の階層構造にしておくのがシンプルではあるかと思います。

コード例

フルでコードを書くとこのような感じになります。

// Kotlin 1.9.0
implementation("androidx.navigation:navigation-compose:2.6.0")
implementation(platform("androidx.compose:compose-bom:2023.03.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
package com.example.scratch

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.List
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navigation

sealed class Route(val route: String) {
    data object Home: Route("home")
    data object Books: Route("books_tab") {
        data object BookList: Route("books")
        data object BookDetail: Route("books/{bookId}") {
            val arguments = listOf(navArgument("bookId") { type = NavType.IntType })
            fun destination(id: Int) = "books/$id"
            fun getBookId(backStackEntry: NavBackStackEntry) = backStackEntry.arguments!!.getInt("bookId")
        }
    }
    data object Settings: Route("settings")
}

class BarItem(
    val label: String,
    val icon: ImageVector,
    val route: String,
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Root() {
    val navController = rememberNavController()

    Scaffold(
        bottomBar = { 
            MyAppBottomBar(navController = navController)
        },
    ) { contentPadding ->
        Box(modifier = Modifier.padding(contentPadding)) {
            MyAppNavHost(navController = navController)
        }
    }
}

@Composable
fun MyAppBottomBar(
    navController: NavController,
) {
    val backStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = backStackEntry?.destination

    val barItems = listOf(
        BarItem("Home", Icons.Outlined.Home, Route.Home.route),
        BarItem("Books", Icons.Outlined.List, Route.Books.route),
        BarItem("Settings", Icons.Outlined.Settings, Route.Settings.route)
    )

    NavigationBar {
        barItems.forEach { item ->
            val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true

            NavigationBarItem(
                selected = selected,
                onClick = {
                    navController.navigate(item.route) {
                        // 最初の画面までバックスタックを popUp する
                        // (タブによる遷移によってバックスタックが際限なく増えることを抑止する
                        popUpTo(navController.graph.findStartDestination().id) {
                            // バックスタックから取り除いた画面の状態を保存しておく?
                            saveState = true
                        }
                        // バックスタックのトップが同じ destination の場合に遷移しない
                        launchSingleTop = true
                        restoreState = true
                    }
                },
                label = { Text(text = item.label) },
                icon = { Icon(item.icon, contentDescription = null) }
            )
        }
    }
}

@Composable
fun MyAppNavHost(
    navController: NavHostController,
) {
    NavHost(
        navController = navController,
        startDestination = Route.Home.route
    ) {
        composable(Route.Home.route) {
            HomeScreen(onNavigateToBook = { id ->
                navController.navigate(Route.Books.BookDetail.destination(id))
            })
        }
        navigation(startDestination = Route.Books.BookList.route, route = Route.Books.route) {
            composable(Route.Books.BookList.route) {
                BookListScreen(onNavigateToBook = { id ->
                    navController.navigate(Route.Books.BookDetail.destination(id))
                })
            }
            composable(
                Route.Books.BookDetail.route,
                arguments = Route.Books.BookDetail.arguments
            ) { backStackEntry ->
                val bookId = Route.Books.BookDetail.getBookId(backStackEntry)
                BookDetailScreen(bookId)
            }
        }

        composable(Route.Settings.route) {
            SettingsScreen()
        }
    }    
}

@Composable
fun HomeScreen(onNavigateToBook: (Int) -> Unit) {
    Column {
        Text("home")

        // 別タブからの遷移テスト
        Button(onClick = { onNavigateToBook(1) }) {
            Text("books:1")
        }
    }
}

@Composable
fun BookListScreen(onNavigateToBook: (Int) -> Unit) {
    Column {
        Text("book list")
        repeat(5) { index ->
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(10.dp)
                    .clickable { onNavigateToBook(index) }
            ) {
                Text("book:$index", fontSize = 20.sp)
            }
        }
    }
}

@Composable
fun SettingsScreen() {
    Text("settings")
}

@Composable
fun BookDetailScreen(bookId: Int) {
    val counter = rememberSaveable { mutableStateOf(0) }
    Column {
        Text("Book Detail $bookId")

        // 状態を変更するテスト
        Button(onClick = { counter.value = counter.value + 1}) {
            Text("count: ${counter.value}")
        }
    }
}

参考

Discussion