第5章 ナビゲーション
5.1 Composeを使用したナビゲーション
画面遷移は自力で書こうとすると大変なため、Navigationと呼ばれるライブラリを利用する事が一般的です。
Jetpack Compose アプリもNavigation コンポーネントがサポートされています。
Jetpack ComposeのNavigation コンポーネントでは、Activity等の移動は行わずにComposableを再生成(再Compose)することで画面遷移を実現します。
Composeのナビゲーションは、Taskの中のActivityのスタック構造を模倣したものとなっています。
Taskの中はスタック構造になっており、その中にActivityが積み上がる形になっています。一番表にあるActivityだけがユーザに見えることによって、画面遷移が実現されます。
5.2 Composeを使えるようにする
Compose をサポートするには、アプリ モジュールの
build.gradle
ファイルで次の依存関係を追加することで利用できます。
dependencies {
/* ... */
implementation("androidx.navigation:navigation-compose:2.5.3")
}
GUIから追加することも出来ます。
- ツールバー > File > Project Structure…
- 左カラムからDependenciesを選択
- Declared Dependenciesの+ボタンから、Library Dependencyを選択
-
androidx.navigation:navigation-compose
で検索し、Versionsは2.5.3にする -
追加したらApplyしてOK
- app/build.gradleに追加されていることが確認できます
5.3 NavController
NavController
は Navigation コンポーネントの中心的な API です。
NavControllerはユーザが見ている現在の画面の情報(currentRoute)を持っており、表示する画面を変える事ができます。
Composableの階層内で、NavController を参照する必要があるすべてのComposableがアクセスできる位置に
NavController
を作成する必要があります。
例えば、下記のようなComposable階層のとき、MainAppに
NavController
を作成します 。
MainApp
├ ArticlesScreen
└ ArticleDetailScreen
状態をComposableの上位に持つようにする事で、Composableをステートレス(入力によってのみ出力が決定される方式)にするプログラミングパターンです。
これにより、以下の特性を得ることができます。
-
信頼できる唯一の情報源(Single Source of Truth: SSOT)
- 状態を複製するのではなく移動することで、信頼できる情報源を 1 つだけにすることができます。これは、バグを防ぐのに役立ちます。
-
カプセル化
- ステートフルComposable(状態を保持する最上位のComposable)のみが状態を変更できます。
-
共有可能
- ホイスティングされた状態は複数のComposableで共有できます。
-
インターセプト可能
- ステートレスなComposableの呼び出し元は、状態を変更する前にイベントを無視するか変更するかを決定できます。
-
分離
- ステートレスなComposableの状態はどこにでも保存できます。
NavController
を作成するには、Composable内で
rememberNavController()
メソッドを使用します。
これにより、構成変更後も存続する
NavController
が作成されて記憶されます。
今回は、状態ホイスティングの原則に基づいてuiパッケージ直下にMainApp.ktというファイルを作成し、そこにナビゲーションの状態を保持するようにします。
-
MainAppを作成します。
-
ui配下にMainApp.ktを作成します
package com.example.strongestnews.ui import androidx.compose.runtime.Composable @Composable fun MainApp() { }
-
MainActivity.ktでは
MainApp
を呼び出すように変更します
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { StrongestNewsTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { MainApp() } } } } }
-
ui配下にMainApp.ktを作成します
-
MainApp.kt内にNavControllerを作成します
import androidx.navigation.compose.rememberNavController @Composable fun MainApp() { val navController = rememberNavController() }
5.4 NavHost
各
NavController
は 1 つの
NavHost
に関連付ける必要があります。
NavHost
は
NavController
に
NavGraph
を関連付けます。
NavGraph
ではComposableの目的地(destination)が定義されており、それらの目的地間を移動できるようになります。
Composable間を移動すると、
NavHost
のコンテンツは自動的に再Composeされます。
NavGraph
内の各コンポーザブルの目的地には「route」が関連付けられています。
routeとは、Composableへのパスを定義する
String
の
State
です。
特定の目的地につながる暗黙的なディープリンク(特定のアプリ要素に移動できるリンク)と考えることができます。
目的地ごとに一意のルートを指定する必要があります。
5.5 Navigation3要素
Navigationの主な要素はNavController、NavGraph、NavHostの3つです。
これらは以下のようにして画面を切り替えます。
- ボタン押下などにより画面を切り替えるイベントが発生
-
NavControllerがrouteを書き換える
- routeはStateなため、再コンポーズが走る
- NavHostがrouteを参照する
- 参照したrouteでNavGraphに問い合わせる
- routeに対応するComposableを取得する
- 画面に表示する
5.6 NavHost の作成
-
MainApp.ktに
NavHost
を作成します。NavHost
を追加して、NavGraph
を作成しましょう。package com.example.strongestnews.ui import androidx.compose.runtime.Composable import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, graph = ) { } }
-
startDestinationでグラフの開始の目的地routeを指定します。
package com.example.strongestnews.ui import androidx.compose.runtime.Composable import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, startDestination = "articles" ) { } }
-
composable()
メソッドを使用して、NavGraph
を定義します。-
composable(){ }
の中には、引数に渡したrouteで表示するComposableを指定します。
package com.example.strongestnews.ui import androidx.compose.runtime.Composable import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.example.strongestnews.articles import com.example.strongestnews.comments import com.example.strongestnews.ui.screens.articleDetail.ArticleDetailContainer import com.example.strongestnews.ui.screens.articles.ArticlesContainer import com.example.strongestnews.ui.screens.comments.CommentsContainer @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, startDestination = "articles" ) { composable("articles") { ArticlesContainer(articles = articles) } composable("articleDetail") { ArticleDetailContainer(article = articles.first()) } composable("comments"){ CommentsContainer(comments = comments) } } }
-
- ビルドしてみると、記事一覧画面が表示されています(まだ画面遷移はできません)
5.7 routeを規定値で指定できるようにする
routeを文字列で指定しているとtypoの可能性が高いので、Sealed Class(enumの拡張版のようなもの)を用いて規定値を定義するように書き換えます。
-
com.example.app.strongestnews
配下にMainRoute.ktを作成し、Sealed Classでrouteを定義するpackage com.example.strongestnews sealed class MainRoute (val route: String) { object Articles: MainRoute("articles") object ArticleDetail: MainRoute("articleDetail") object Comments: MainRoute("comments") }
-
MainAppで規定値を使用するように書き換える
package com.example.strongestnews.ui import androidx.compose.runtime.Composable import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.example.strongestnews.MainRoute import com.example.strongestnews.articles import com.example.strongestnews.comments import com.example.strongestnews.ui.screens.articleDetail.ArticleDetailContainer import com.example.strongestnews.ui.screens.articles.ArticlesContainer import com.example.strongestnews.ui.screens.comments.CommentsContainer @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, startDestination = MainRoute.Articles.route ) { composable(MainRoute.Articles.route) { ArticlesContainer(articles = articles) } composable(MainRoute.ArticleDetail.route) { ArticleDetailContainer(article = articles.first()) } composable(MainRoute.Comments.route){ CommentsContainer(comments = comments) } } }
5.8 Composableに移動する
NavGraph
内でComposableから移動するには、
navigate()
を呼び出します。
navigate()
は、先ほど指定した目的地のrouteを表す単一の
String
パラメータを使うことで遷移先を決定できます。ページからページへ遷移するとき、コンポーザブルに画面遷移の関数をコールバックとして渡すようにします。
ここで、
XXXContainer
コンポーザブルの上層にもう一つ
XXXScreen
コンポーザブルを挟むように変更し、
XXXScreen
コンポーザブルにナビゲーション系の関数を引数に渡すようにしておきます。
詳しくは第6章のデータ層にて解説しますが、簡潔に説明すると、画面のプレビューがこの後もできるようにするためです。
-
ArticlesScreen
コンポーザブルを作成する@Composable fun ArticlesScreen( articles: List<Article> ){ ArticlesContainer(articles = articles) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ArticlesContainer( /* ... */
-
Screenコンポーネントで画面遷移の関数をコールバックとして受け取れるようにします。
@Composable fun ArticlesScreen( articles: List<Article>, onNavigateToArticleDetail: () -> Unit, ){ ArticlesContainer(articles = articles) }
-
ArticleCardコンポーネントにコールバックを渡します
package com.example.strongestnews.ui.screens.articles import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.strongestnews.Article import com.example.strongestnews.R import com.example.strongestnews.articles import com.example.strongestnews.ui.screens.articles.components.ArticleCard import com.example.strongestnews.ui.theme.StrongestNewsTheme @Composable fun ArticlesScreen( articles: List<Article>, onNavigateToArticleDetail: () -> Unit, ){ ArticlesContainer( articles = articles, onClickSeeArticleDetail = onNavigateToArticleDetail ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ArticlesContainer( articles: List<Article>, onClickSeeArticleDetail: () -> Unit, ) { Scaffold( topBar = { MediumTopAppBar( title = { Text(stringResource(R.string.articles_screen_title)) }, ) }, ) { innerPadding -> Column( modifier = Modifier .verticalScroll(rememberScrollState()) .padding(innerPadding) .padding(30.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { articles.forEach { article -> ArticleCard( title = article.title, imageURL = article.imageURL, createdAt = article.createdAt, onClick = onClickSeeArticleDetail ) } } } } @Preview(showBackground = true) @Composable fun ArticlesPreview() { StrongestNewsTheme { ArticlesContainer( articles = articles, onClickSeeArticleDetail = {} ) } }
-
MainApp.ktで
ArticlesScreen
を呼び出すように変更し、コールバックにnavigate()
を渡しますpackage com.example.strongestnews.ui import androidx.compose.runtime.Composable import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.example.strongestnews.MainRoute import com.example.strongestnews.articles import com.example.strongestnews.comments import com.example.strongestnews.ui.screens.articleDetail.ArticleDetailContainer import com.example.strongestnews.ui.screens.articles.ArticlesScreen import com.example.strongestnews.ui.screens.comments.CommentsContainer @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, startDestination = MainRoute.Articles.route ) { composable(MainRoute.Articles.route) { ArticlesScreen( articles = articles, onNavigateToArticleDetail = { navController.navigate(MainRoute.ArticleDetail.route) } ) } composable(MainRoute.ArticleDetail.route) { ArticleDetailContainer(article = articles.first()) } composable(MainRoute.Comments.route){ CommentsContainer(comments = comments) } } }
再コンポーズのたびに
navigate()
が呼び出されるのを避けるため、
navigate()
は、コンポーザブル自体の一部としてではなく、常にコールバックの一部として呼び出してください。
デフォルトでは、
navigate()
は新しいdestination(目的地)をBackStack(画面遷移履歴)に追加します。
画面の遷移をスタックに積み上げたようなものです。
戻るボタン等はこのスタックからpopしたりすることで制御しています。
navigate()
の動作を変更するには、追加のナビゲーション オプションを
navigate()
呼び出しにアタッチします。
// detail画面に遷移するとき、articles画面より前のBackStackを削除する
navController.navigate(MainRoute.ArticleDetail.route) {
popUpTo(MainRoute.Articles.route)
}
// detail画面に遷移するとき、articles画面を含めて全てのBackStackを削除する
navController.navigate(MainRoute.ArticleDetail.route) {
popUpTo(MainRoute.Articles.route) { inclusive = true }
}
// 一つ前の遷移に戻る
navController.popBackStack()
5.9 前の画面に戻れるようにする
このままでは戻るボタンで前の画面に戻れないため、前の画面に戻れるようにします。
navController.popBackStack()
により前の画面に戻ることができます。
記事詳細画面から記事一覧画面に戻る
-
まずは記事一覧画面と同様に、
ArticleDetailContainer
をArticleDetailScreen
で囲うように変更します/* ... */ @Composable fun ArticleDetailScreen( article: Article ) { ArticleDetailContainer(article = article) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ArticleDetailContainer( article: Article /* ... */
-
ArticleDetailScreenに前の画面に戻るための画面遷移の関数をコールバックとして渡します
-
最上位の
ArticleDetailScreen
で受け取った後、実際に画面遷移の関数を実行するようにしたいコンポーザブルまで、関数をバケツリレーしていきます-
今回の場合、トップバーの「 < 」マークを押した時に前の画面に戻るようにしたいので、
MediumTopAppBar
のIconButton
のonClick
に渡します
-
今回の場合、トップバーの「 < 」マークを押した時に前の画面に戻るようにしたいので、
- プレビューに空の関数を渡すように修正します
/* ... */ @Composable fun ArticleDetailScreen( article: Article, onNavigateToBack: () -> Unit, ) { ArticleDetailContainer( article = article, onNavigateToBack = onNavigateToBack ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ArticleDetailContainer( article: Article, onNavigateToBack: () -> Unit, ){ // Composeによる追跡がされる値 var favoriteCount = remember { mutableStateOf(0) } Scaffold( topBar = { MediumTopAppBar( title = { Text(stringResource(R.string.articles_screen_title)) }, navigationIcon = { IconButton( onClick = onNavigateToBack ) { Icon( Icons.Filled.ArrowBack, contentDescription = "Navigation Back" ) } /* ... */ @Preview(showBackground = true) @Composable fun ArticleDetailScreenPreview() { StrongestNewsTheme { ArticleDetailContainer( article = articles[0], onNavigateToBack = {} ) } }
-
最上位の
-
MainAppから
ArticleDetailScreen
を呼び出すように修正し、画面遷移の関数を渡すように変更しますpackage com.example.strongestnews.ui import androidx.compose.runtime.Composable import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.example.strongestnews.MainRoute import com.example.strongestnews.articles import com.example.strongestnews.comments import com.example.strongestnews.ui.screens.articleDetail.ArticleDetailScreen import com.example.strongestnews.ui.screens.articles.ArticlesScreen import com.example.strongestnews.ui.screens.comments.CommentsContainer @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, startDestination = MainRoute.Articles.route ) { composable(MainRoute.Articles.route) { ArticlesScreen( articles = articles, onNavigateToArticleDetail = { navController.navigate(MainRoute.ArticleDetail.route) } ) } composable(MainRoute.ArticleDetail.route) { ArticleDetailScreen( article = articles.first(), onNavigateToBack = { navController.popBackStack() } ) } composable(MainRoute.Comments.route){ CommentsContainer(comments = comments) } } }
仕様変更の際に変更範囲が膨大かつ分散することを防ぐため、画面遷移の定義は一箇所で集中管理するようにすることが一般的となっています。
そのため、実運用上は集中管理できる仕組みを設計として取り入れることが多いです。
Fragment時代のNavigationの仕組みを参考にした実装例
想定外の画面間での遷移が発生しないよう、MainApp側で全ての画面遷移を管理するようにし、Direction(1つの画面から呼び出せる遷移先セット)として各Screenに渡す仕組みにしています。
-
Screen側に画面遷移を定義します。
-
HomeDirection.ktを作成し、Directionを定義する。
package com.example.app.emtg.ui.screens.home data class HomeDirection ( val navigateToResult: () -> Unit )
-
HomeScreen.ktにdirectionsを指定します。
@Composable fun HomeScreen( directions: HomeDirection = HomeDirection({}) ) { /*...*/ }
-
HomeDirection.ktを作成し、Directionを定義する。
-
各Composable関数に定義した引数にMainApp.ktから実際の遷移処理を代入します。
-
MainApp.ktに画面遷移の処理を追加します。
// MainApp.kt @Composable fun MainApp( ) { val navController = rememberNavController() NavHost(navController = navController, startDestination = MainRoute.Home.route) { composable(MainRoute.Home.route) { // Composition生成時に画面遷移処理をまとめて与えてあげる HomeScreen( directions = HomeDirection( navigateToResult = { navController.navigate(MainRoute.Result.route) } ) ) } composable(MainRoute.Result.route) { ResultScreen( directions = ResultDirection( navigateUp = { // ひとつ前に戻る場合はnavigateUpを使います navController.navigateUp() } ) ) } } }
-
MainApp.ktに画面遷移の処理を追加します。
-
クリック時に遷移するように、Screen側に関数呼び出しを追加します。
// HomeScreen.kt package com.example.app.emtg.ui.screens.home @Composable fun HomeScreen( directions: HomeDirection = HomeDirection({}) ) { var description by remember { mutableStateOf("") } var title by remember { mutableStateOf("Hello, Android!") } Scaffold( topBar = { TopAppBar(title = { Text(text = "emtgアプリ") }) } ) { Column { InputField( description = description, onChanged = { description = it }, // クリックしたら画面遷移するように変更 onClick = { directions.navigateToResult() } ) MessageItem( title = title, ) } } }
5.10 引数を使用して移動する
Navigation Compose では、引数のプレースホルダをルートに追加することで、Composableの目的地間で引数を渡すこともできます。
下記のようにURLのパスのように記述することができます。
NavHost(startDestination = "articles") {
/*...*/
composable("articleDetail/{articleId}") {/*...*/}
}
記事一覧画面から記事詳細画面に遷移する際に、記事IDを受け取れるようにする
デフォルトでは、すべての引数が文字列として解析されます。
arguments
パラメータを使用して
type
を設定することで、別の型を指定できます。
記事詳細のrouteを書き換え、argumentsで文字列として解析されるように指定します。
/* ... */
composable(
"${MainRoute.ArticleDetail.route}/{articleID}",
// 引数の型を指定
arguments = listOf(
navArgument("articleID") {
type = NavType.StringType
}
)
) {
ArticleDetailScreen(
article = articles.first(),
onNavigateToBack = {
navController.popBackStack()
}
)
}
/* ... */
ルートで渡された引数を取り出して、コンポーザブルに渡すようにします。
NavArguments
は、
NavBackStackEntry
から値を取り出すことでコンポーザブルに渡すことができます。
また、受け取った記事IDを使って記事詳細を取得するように変更したいので、articleで記事詳細を渡している箇所は削除します。
/* ... */
composable(
"${MainRoute.ArticleDetail.route}/{articleID}",
// 引数の型を指定
arguments = listOf(
navArgument("articleID") {
type = NavType.StringType
}
)
) { backStackEntry -> // itのままでもいいが、わかりやすくするため名前をつけておく
// 値を受け取る
val receivedArticleID = backStackEntry.arguments?.getString("articleID")!!
ArticleDetailScreen(
// この後コンポーザブルに新設するつもりで引数渡しちゃう
articleID = receivedArticleID,
// articleIDを使って取得するようにするので、articleは消す
// article = articles.first(),
onNavigateToBack = {
navController.popBackStack()
}
)
}
/* ... */
遷移先のComposableで引数を受け取り、記事IDから記事詳細を表示するように変更していきます。
-
ArticleDetailScreen
コンポーザブルで引数を受け取りますまた、引数の
article
は使わなくなったので削除します@Composable fun ArticleDetailScreen( articleID: String, // article: Article, onNavigateToBack: () -> Unit, ) { ArticleDetailContainer( article = article, onNavigateToBack = onNavigateToBack ) }
-
記事IDから記事を取得し、
ArticleDetailContainer
に渡して表示できるようにしますViewにロジックを混入してはいけないです。本来、この処理はviewModelでやることです。
Viewにロジックを混入することは 絶対にやってはいけないことです が、演習の流れの関係で一時的にここに実装します。許してください。
次の章でここをリファクタしていきます。
@Composable fun ArticleDetailScreen( articleID: String, onNavigateToBack: () -> Unit, ) { //articlesからarticleIdが一致する要素取得。なければ最初の要素 val article = articles.find { it.id == articleID } ?: articles.first() ArticleDetailContainer( article = article, onNavigateToBack = onNavigateToBack ) }
-
ArticlesScreen
コンポーザブルの記事詳細への画面遷移のコールバックで文字列を受け取れるように修正し、ArticleCardコンポーザブルに画面遷移関数を渡す際に引数としてarticle.id
を指定するようにします引数のある関数を子コンポーザブルに渡すときは
{}
で囲う必要がある点に注意してください。package com.example.strongestnews.ui.screens.articles import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.strongestnews.Article import com.example.strongestnews.R import com.example.strongestnews.articles import com.example.strongestnews.ui.screens.articles.components.ArticleCard import com.example.strongestnews.ui.theme.StrongestNewsTheme @Composable fun ArticlesScreen( articles: List<Article>, onNavigateToArticleDetail: (String) -> Unit, ){ ArticlesContainer( articles = articles, onClickSeeArticleDetail = onNavigateToArticleDetail ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ArticlesContainer( articles: List<Article>, onClickSeeArticleDetail: (String) -> Unit, ) { Scaffold( topBar = { MediumTopAppBar( title = { Text(stringResource(R.string.articles_screen_title)) }, ) }, ) { innerPadding -> Column( modifier = Modifier .verticalScroll(rememberScrollState()) .padding(innerPadding) .padding(30.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { articles.forEach { article -> ArticleCard( title = article.title, imageURL = article.imageURL, createdAt = article.createdAt, onClick = { onClickSeeArticleDetail(article.id) } // articleIDを指定 ) } } } } @Preview(showBackground = true) @Composable fun ArticlesPreview() { StrongestNewsTheme { ArticlesContainer( articles = articles, onClickSeeArticleDetail = {} ) } }
どこまで値をバケツリレーするといいのか?articleID
はArticleCard
コンポーザブルまでリレーしなくていいのか?と思った人もいるかもしれないですね。どこまで値をバケツリレーリレーするかどうかはコンポーザブルの責任範囲に関わってきます。今回の実装では
ArticleCard
は記事情報を表示することと押されたら何らかの処理をすることにのみ責任を持ち、押された際にどんな処理をするかには責任を持ちません。
これを仮にarticleID
をArticleCard
の引数として受け取る場合、ArticleCard
はarticleID
を使用して遷移するというロジックに責任を持つことになります。コンポーザブルがどのような責任を持つかは開発者がプロジェクトごとに決定することであり、一定のルールを決めて運用すると良いでしょう。
-
MainApp.ktでArticleScreenの引数である記事詳細へ遷移するためのコールバックを、routeに引数をくっ付けるように修正します
遷移する時に値を渡すには、
navigate
の呼び出し内のプレースホルダに代わって、routeに値を追加します。/* ... */ @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, startDestination = MainRoute.Articles.route ) { composable(MainRoute.Articles.route) { ArticlesScreen( articles = articles, // 引数でarticleIdを受け取り、articleIdをrouteにくっ付ける onNavigateToArticleDetail = { articleID -> navController.navigate("${MainRoute.ArticleDetail.route}/$articleID") } ) } /* ... */
アプリをビルドして起動してみましょう。記事一覧画面→それぞれの記事詳細 へと遷移できれば成功です。
Navigation Compose は、省略可能なNavigation引数もサポートしています。省略可能な引数は、次の 2 つの点で必須の引数とは異なります。
-
クエリ パラメータの構文(
"?argName={argName}"
)を使用して指定する必要があります。 -
defaultValue
を設定するか、nullability = true
(デフォルト値を暗黙的にnull
に設定)を指定する必要があります。
つまり、省略可能なすべての引数をリストとして
composable()
関数に明示的に追加する必要があります。
composable(
"detail?articleId={articleId}",
arguments = listOf(navArgument("articleId") { defaultValue = "hoge" })
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId")
Profile(navController, articleId)
}
5.11 記事詳細からコメント画面への遷移時に記事IDを渡す
それでは、記事一覧↔記事詳細への画面遷移と同様に、記事詳細↔コメントの画面遷移を以下のようになるように実装してみましょう!
-
記事詳細画面にコメント画面への画面遷移関数を引数で受け取って実装します
実装例
package com.example.strongestnews.ui.screens.articleDetail import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.strongestnews.Article import com.example.strongestnews.R import com.example.strongestnews.articles import com.example.strongestnews.ui.theme.StrongestNewsTheme @Composable fun ArticleDetailScreen( articleID: String, onNavigateToBack: () -> Unit, onNavigateToComment: (String) -> Unit, ) { val article = articles.find { it.id == articleID } ?: articles.first() ArticleDetailContainer( article = article, onNavigateToBack = onNavigateToBack, onNavigateToComment = onNavigateToComment, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ArticleDetailContainer( article: Article, onNavigateToBack: () -> Unit, onNavigateToComment: (String) -> Unit, ){ // Composeによる追跡がされる値 var favoriteCount = remember { mutableStateOf(0) } Scaffold( topBar = { MediumTopAppBar( title = { Text(stringResource(R.string.articles_screen_title)) }, navigationIcon = { IconButton( onClick = onNavigateToBack ) { Icon( Icons.Filled.ArrowBack, contentDescription = "Navigation Back" ) } } ) }, ) { innerPadding -> Column( modifier = Modifier .verticalScroll(rememberScrollState()) .fillMaxWidth() .padding(innerPadding) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp) ) { AsyncImage( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) .clip(RoundedCornerShape(10.dp)), contentScale = ContentScale.FillWidth, model = ImageRequest .Builder(LocalContext.current) .data(article.imageURL.toString()) .crossfade(true) .placeholder(R.drawable.placeholder) .error(R.drawable.placeholder) .build(), contentDescription = article.title ) Column { Text( text = article.createdAt.toString(), style = MaterialTheme.typography.labelLarge ) Spacer(modifier = Modifier.height(5.dp)) Text( text = article.title, style = MaterialTheme.typography.titleLarge ) } Row( horizontalArrangement = Arrangement.spacedBy(10.dp) ) { AssistChip( onClick = { favoriteCount.value++ }, label = { Text(text = favoriteCount.value.toString())}, leadingIcon = { Icon( Icons.Filled.Favorite, contentDescription = "favorite", Modifier.size(AssistChipDefaults.IconSize) ) } ) AssistChip( onClick = {}, label = { Text(text = "Share") }, leadingIcon = { Icon( Icons.Filled.Share, contentDescription = "Share", Modifier.size(AssistChipDefaults.IconSize) ) } ) } OutlinedButton( modifier = Modifier .fillMaxWidth(), onClick = { onNavigateToComment(article.id) } ) { Text(text = "コメントを表示") } Text( text = article.detail, style = MaterialTheme.typography.bodyLarge ) } } } @Preview(showBackground = true) @Composable fun ArticleDetailScreenPreview() { StrongestNewsTheme { ArticleDetailContainer( article = articles[0], onNavigateToBack = {}, onNavigateToComment = {} ) } }
-
CommentsContainer
をCommentScreen
で囲うようにしてから、articleID
を受け取るようにします実装例
package com.example.strongestnews.ui.screens.comments import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Send import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.strongestnews.Comment import com.example.strongestnews.R import com.example.strongestnews.comments import com.example.strongestnews.ui.screens.comments.components.CommentItem import com.example.strongestnews.ui.theme.StrongestNewsTheme @Composable fun CommentsScreen( articleID: String, onNavigateToBack: () -> Unit, ) { val comments = comments CommentsContainer( comments = comments, onNavigateToBack = onNavigateToBack ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommentsContainer( comments: List<Comment>, onNavigateToBack: () -> Unit, ){ var text by rememberSaveable( stateSaver = TextFieldValue.Saver ) { mutableStateOf(TextFieldValue("", TextRange(0, 7))) } Scaffold( topBar = { MediumTopAppBar( title = { Text(stringResource(R.string.articles_screen_title)) }, navigationIcon = { IconButton( onClick = onNavigateToBack ) { Icon( Icons.Filled.ArrowBack, contentDescription = "Navigation Back" ) } } ) }, ) {innerPadding -> Column( modifier = Modifier .fillMaxWidth() .padding(innerPadding) ) { // コメントリスト Column( modifier = Modifier .verticalScroll(rememberScrollState()) .weight(1f) .padding(20.dp) ) { comments.forEach {comment -> CommentItem(comment = comment) } } // 下の部分 Column { Divider() TextField( modifier = Modifier .fillMaxWidth() .padding(20.dp), value = text, onValueChange = { text = it }, placeholder = { Text(text = "Comment") }, trailingIcon = { IconButton( onClick = {}, enabled = text.text.isNotEmpty() ) { Icon( Icons.Filled.Send, contentDescription = "Send" ) } }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Send ), keyboardActions = KeyboardActions(onSend = {}) ) } } } } @Preview(showBackground = true) @Composable fun CommentsScreenPreview(){ StrongestNewsTheme { CommentsContainer( comments = comments, onNavigateToBack = {} ) } }
-
MainApp
を修正します実装例
package com.example.strongestnews.ui import androidx.compose.runtime.Composable import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.example.strongestnews.MainRoute import com.example.strongestnews.articles import com.example.strongestnews.ui.screens.articleDetail.ArticleDetailScreen import com.example.strongestnews.ui.screens.articles.ArticlesScreen import com.example.strongestnews.ui.screens.comments.CommentsScreen @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, startDestination = MainRoute.Articles.route ) { composable(MainRoute.Articles.route) { ArticlesScreen( articles = articles, onNavigateToArticleDetail = { articleID -> navController.navigate("${MainRoute.ArticleDetail.route}/$articleID") } ) } composable( "${MainRoute.ArticleDetail.route}/{articleID}", arguments = listOf( navArgument("articleID") { type = NavType.StringType } ) ) { backStackEntry -> val receivedArticleID = backStackEntry.arguments?.getString("articleID")!! ArticleDetailScreen( articleID = receivedArticleID, onNavigateToBack = { navController.popBackStack() }, onNavigateToComment = { articleID -> navController.navigate("${MainRoute.Comments.route}/$articleID") } ) } composable( "${MainRoute.Comments.route}/{articleID}", arguments = listOf( navArgument("articleID") { type = NavType.StringType } ) ){ backStackEntry -> val receivedArticleID = backStackEntry.arguments?.getString("articleID")!! CommentsScreen( articleID = receivedArticleID, onNavigateToBack = { navController.popBackStack() } ) } } }
-
コメント画面から記事詳細画面に戻れるようにします
実装例
package com.example.strongestnews.ui.screens.comments import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Send import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.strongestnews.Comment import com.example.strongestnews.R import com.example.strongestnews.comments import com.example.strongestnews.ui.screens.comments.components.CommentItem import com.example.strongestnews.ui.theme.StrongestNewsTheme @Composable fun CommentsScreen( articleID: String, onNavigateToBack: () -> Unit, ) { val comments = comments CommentsContainer( comments = comments, onNavigateToBack = onNavigateToBack ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommentsContainer( comments: List<Comment>, onNavigateToBack: () -> Unit, ){ var text by rememberSaveable( stateSaver = TextFieldValue.Saver ) { mutableStateOf(TextFieldValue("", TextRange(0, 7))) } Scaffold( topBar = { MediumTopAppBar( title = { Text(stringResource(R.string.articles_screen_title)) }, navigationIcon = { IconButton( onClick = { onNavigateToBack } ) { Icon( Icons.Filled.ArrowBack, contentDescription = "Navigation Back" ) } } ) }, ) {innerPadding -> Column( modifier = Modifier .fillMaxWidth() .padding(innerPadding) ) { // コメントリスト Column( modifier = Modifier .verticalScroll(rememberScrollState()) .weight(1f) .padding(20.dp) ) { comments.forEach {comment -> CommentItem(comment = comment) } } // 下の部分 Column { Divider() TextField( modifier = Modifier .fillMaxWidth() .padding(20.dp), value = text, onValueChange = { text = it }, placeholder = { Text(text = "Comment") }, trailingIcon = { IconButton( onClick = {}, enabled = text.text.isNotEmpty() ) { Icon( Icons.Filled.Send, contentDescription = "Send" ) } }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Send ), keyboardActions = KeyboardActions(onSend = {}) ) } } } } @Preview(showBackground = true) @Composable fun CommentsScreenPreview(){ StrongestNewsTheme { CommentsContainer( comments = comments, onNavigateToBack = {} ) } }
package com.example.strongestnews.ui import androidx.compose.runtime.Composable import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.example.strongestnews.MainRoute import com.example.strongestnews.articles import com.example.strongestnews.ui.screens.articleDetail.ArticleDetailScreen import com.example.strongestnews.ui.screens.articles.ArticlesScreen import com.example.strongestnews.ui.screens.comments.CommentsScreen @Composable fun MainApp() { val navController = rememberNavController() NavHost( navController = navController, startDestination = MainRoute.Articles.route ) { composable(MainRoute.Articles.route) { ArticlesScreen( articles = articles, onNavigateToArticleDetail = { articleID -> navController.navigate("${MainRoute.ArticleDetail.route}/$articleID") } ) } composable( "${MainRoute.ArticleDetail.route}/{articleID}", arguments = listOf( navArgument("articleID") { type = NavType.StringType } ) ) { backStackEntry -> val receivedArticleID = backStackEntry.arguments?.getString("articleID")!! ArticleDetailScreen( articleID = receivedArticleID, onNavigateToBack = { navController.popBackStack() }, onNavigateToComment = {articleID -> navController.navigate(articleID) } ) } composable( "${MainRoute.Comments.route}/{articleID}", arguments = listOf( navArgument("articleID") { type = NavType.StringType } ) ){ backStackEntry -> val receivedArticleID = backStackEntry.arguments?.getString("articleID")!! CommentsScreen( articleID = receivedArticleID, onNavigateToBack = { navController.popBackStack() } ) } } }
5.2 実機で動かしてみよう
画面遷移ができるようになったので、実際に実機で動かしてみましょう!
- androidの開発者モードを有効にします
- 検証機を用意しているので、PCにUSBで繋げます。
- USBでバックを有効にします
- Android Studioの実行端末のプルダウンで繋いだ実機を選べるようになるので選択し、実行します。