🚀 ニフティ’s Notion

Androidアプリ開発入門#5

第5章 ナビゲーション

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

画面遷移は自力で書こうとすると大変なため、Navigationと呼ばれるライブラリを利用する事が一般的です。

Jetpack Compose アプリもNavigation コンポーネントがサポートされています。

Jetpack ComposeのNavigation コンポーネントでは、Activity等の移動は行わずにComposableを再生成(再Compose)することで画面遷移を実現します。

Composeのナビゲーションは、Taskの中のActivityのスタック構造を模倣したものとなっています。

Taskの中はスタック構造になっており、その中にActivityが積み上がる形になっています。一番表にあるActivityだけがユーザに見えることによって、画面遷移が実現されます。

image block
5.2 Composeを使えるようにする

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

dependencies {
		/* ... */
    implementation("androidx.navigation:navigation-compose:2.5.3")
}
app/build.gradle

GUIから追加することも出来ます。

  1. ツールバー > File > Project Structure…
    image block
  2. 左カラムからDependenciesを選択
    image block
  3. Declared Dependenciesの+ボタンから、Library Dependencyを選択
    image block
  4. androidx.navigation:navigation-compose で検索し、Versionsは2.5.3にする
    image block
  5. 追加したらApplyしてOK
    1. app/build.gradleに追加されていることが確認できます

5.3 NavController

NavController  は Navigation コンポーネントの中心的な API です。

NavControllerはユーザが見ている現在の画面の情報(currentRoute)を持っており、表示する画面を変える事ができます。

Composableの階層内で、NavController を参照する必要があるすべてのComposableがアクセスできる位置に NavController  を作成する必要があります。

例えば、下記のようなComposable階層のとき、MainAppに NavController を作成します 。

MainApp
├ ArticlesScreen
└ ArticleDetailScreen
Icon
状態ホイスティングの原則

状態をComposableの上位に持つようにする事で、Composableをステートレス(入力によってのみ出力が決定される方式)にするプログラミングパターンです。

これにより、以下の特性を得ることができます。

  • 信頼できる唯一の情報源(Single Source of Truth: SSOT)
    • 状態を複製するのではなく移動することで、信頼できる情報源を 1 つだけにすることができます。これは、バグを防ぐのに役立ちます。
  • カプセル化
    • ステートフルComposable(状態を保持する最上位のComposable)のみが状態を変更できます。
  • 共有可能
    • ホイスティングされた状態は複数のComposableで共有できます。
  • インターセプト可能
    • ステートレスなComposableの呼び出し元は、状態を変更する前にイベントを無視するか変更するかを決定できます。
  • 分離
    • ステートレスなComposableの状態はどこにでも保存できます。

image block

NavController  を作成するには、Composable内で  rememberNavController()  メソッドを使用します。

これにより、構成変更後も存続する NavController が作成されて記憶されます。

今回は、状態ホイスティングの原則に基づいてuiパッケージ直下にMainApp.ktというファイルを作成し、そこにナビゲーションの状態を保持するようにします。

  1. MainAppを作成します。
    1. ui配下にMainApp.ktを作成します
      image block
      package com.example.strongestnews.ui
      
      import androidx.compose.runtime.Composable
      
      @Composable
      fun MainApp() {
      }
      MainApp.kt
    2. 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()
                    }
                }
            }
        }
    }
    MainActivity.kt
  2. MainApp.kt内にNavControllerを作成します
    import androidx.navigation.compose.rememberNavController
    
    @Composable
    fun MainApp() {
        val navController = rememberNavController()
    }
    MainApp.kt

5.4 NavHost

各  NavController  は 1 つの  NavHost  に関連付ける必要があります。

NavHost  は  NavController  に NavGraph を関連付けます。

NavGraph ではComposableの目的地(destination)が定義されており、それらの目的地間を移動できるようになります。

Composable間を移動すると、 NavHost  のコンテンツは自動的に再Composeされます。

NavGraph 内の各コンポーザブルの目的地には「route」が関連付けられています。

Icon
routeとは

routeとは、Composableへのパスを定義する  String  の State です。

特定の目的地につながる暗黙的なディープリンク(特定のアプリ要素に移動できるリンク)と考えることができます。

目的地ごとに一意のルートを指定する必要があります。

5.5 Navigation3要素

Navigationの主な要素はNavController、NavGraph、NavHostの3つです。

これらは以下のようにして画面を切り替えます。

image block

  1. ボタン押下などにより画面を切り替えるイベントが発生
  2. NavControllerがrouteを書き換える
    1. routeはStateなため、再コンポーズが走る
    2. NavHostがrouteを参照する
  3. 参照したrouteでNavGraphに問い合わせる
  4. routeに対応するComposableを取得する
  5. 画面に表示する

5.6 NavHost の作成
  1. 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 =
        ) {
    
    		}
    }
    MainActivity.kt
  2. 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"
        ) {
            
        }
    }
  3. composable() メソッドを使用して、 NavGraph を定義します。
    1. 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) }
        }
    }
    MainApp.kt
  4. ビルドしてみると、記事一覧画面が表示されています(まだ画面遷移はできません)
    image block
5.7 routeを規定値で指定できるようにする

routeを文字列で指定しているとtypoの可能性が高いので、Sealed Class(enumの拡張版のようなもの)を用いて規定値を定義するように書き換えます。

  1. com.example.app.strongestnews 配下にMainRoute.ktを作成し、Sealed Classでrouteを定義する
    image block
    package com.example.strongestnews
    
    sealed class MainRoute (val route: String) {
        object Articles: MainRoute("articles")
        object ArticleDetail: MainRoute("articleDetail")
        object Comments: MainRoute("comments")
    }
    MainAppState.kt
  2. 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) }
        }
    }
    MainApp.kt
5.8 Composableに移動する

NavGraph 内でComposableから移動するには、 navigate()  を呼び出します。

navigate()  は、先ほど指定した目的地のrouteを表す単一の  String  パラメータを使うことで遷移先を決定できます。ページからページへ遷移するとき、コンポーザブルに画面遷移の関数をコールバックとして渡すようにします。

ここで、 XXXContainer コンポーザブルの上層にもう一つ XXXScreen コンポーザブルを挟むように変更し、 XXXScreen コンポーザブルにナビゲーション系の関数を引数に渡すようにしておきます。

詳しくは第6章のデータ層にて解説しますが、簡潔に説明すると、画面のプレビューがこの後もできるようにするためです。

  1. ArticlesScreen コンポーザブルを作成する
    @Composable
    fun ArticlesScreen(
        articles: List<Article>
    ){
        ArticlesContainer(articles = articles)
    }
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun ArticlesContainer(
    
    /* ... */
  2. Screenコンポーネントで画面遷移の関数をコールバックとして受け取れるようにします。
    @Composable
    fun ArticlesScreen(
        articles: List<Article>,
        onNavigateToArticleDetail: () -> Unit,
    ){
        ArticlesContainer(articles = articles)
    }
  3. 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 = {}
            )
        }
    }
  4. 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(画面遷移履歴)に追加します。

Icon
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() により前の画面に戻ることができます。

記事詳細画面から記事一覧画面に戻る

  1. まずは記事一覧画面と同様に、 ArticleDetailContainer ArticleDetailScreen で囲うように変更します
    /* ... */
    
    @Composable
    fun ArticleDetailScreen(
        article: Article
    ) {
        ArticleDetailContainer(article = article)
    }
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun ArticleDetailContainer(
        article: Article
    
    /* ... */
  2. ArticleDetailScreenに前の画面に戻るための画面遷移の関数をコールバックとして渡します
    1. 最上位の ArticleDetailScreen で受け取った後、実際に画面遷移の関数を実行するようにしたいコンポーザブルまで、関数をバケツリレーしていきます
      1. 今回の場合、トップバーの「 < 」マークを押した時に前の画面に戻るようにしたいので、 MediumTopAppBar IconButton onClick に渡します
    2. プレビューに空の関数を渡すように修正します
    /* ... */
    
    @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 = {}
            )
        }
    }
  3. 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) }
        }
    }

Icon
画面遷移の定義

仕様変更の際に変更範囲が膨大かつ分散することを防ぐため、画面遷移の定義は一箇所で集中管理するようにすることが一般的となっています。

そのため、実運用上は集中管理できる仕組みを設計として取り入れることが多いです。

Fragment時代のNavigationの仕組みを参考にした実装例

想定外の画面間での遷移が発生しないよう、MainApp側で全ての画面遷移を管理するようにし、Direction(1つの画面から呼び出せる遷移先セット)として各Screenに渡す仕組みにしています。

  1. Screen側に画面遷移を定義します。
    1. HomeDirection.ktを作成し、Directionを定義する。
      package com.example.app.emtg.ui.screens.home
      
      data class HomeDirection (
      		val navigateToResult: () -> Unit
      )
      HomeDirection.kt
    2. HomeScreen.ktにdirectionsを指定します。
      @Composable
      fun HomeScreen(
      		directions: HomeDirection = HomeDirection({})
      ) {
      		/*...*/
      }
      HomeScreen.kt
  2. 各Composable関数に定義した引数にMainApp.ktから実際の遷移処理を代入します。
    1. 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()
      										}
      								)
      						) 
      				}
          }
      }
  3. クリック時に遷移するように、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,
                )
            }
        }
    }
    HomeScreen.kt

5.10 引数を使用して移動する

Navigation Compose では、引数のプレースホルダをルートに追加することで、Composableの目的地間で引数を渡すこともできます。

下記のようにURLのパスのように記述することができます。

NavHost(startDestination = "articles") {
    /*...*/
    composable("articleDetail/{articleId}") {/*...*/}
}
MainApp.kt

記事一覧画面から記事詳細画面に遷移する際に、記事IDを受け取れるようにする

デフォルトでは、すべての引数が文字列として解析されます。 arguments  パラメータを使用して  type  を設定することで、別の型を指定できます。

記事詳細のrouteを書き換え、argumentsで文字列として解析されるように指定します。

/* ... */

composable(
    "${MainRoute.ArticleDetail.route}/{articleID}",
    // 引数の型を指定
    arguments = listOf(
        navArgument("articleID") {
            type = NavType.StringType
        }
    )
) {
    ArticleDetailScreen(
        article = articles.first(),
        onNavigateToBack = {
            navController.popBackStack()
        }
    )
}

/* ... */
MainApp.kt

ルートで渡された引数を取り出して、コンポーザブルに渡すようにします。

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()
        }
    )
}

/* ... */
MainApp.kt

遷移先のComposableで引数を受け取り、記事IDから記事詳細を表示するように変更していきます。

  1. ArticleDetailScreen コンポーザブルで引数を受け取ります

    また、引数の article は使わなくなったので削除します

    @Composable
    fun ArticleDetailScreen(
        articleID: String,
        // article: Article,
        onNavigateToBack: () -> Unit,
    ) {
        ArticleDetailContainer(
            article = article,
            onNavigateToBack = onNavigateToBack
        )
    }
  2. 記事IDから記事を取得し、 ArticleDetailContainer に渡して表示できるようにします
    Icon
    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
        )
    }
  3. 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 = {}
            )
        }
    }
    ArticlesScreen.kt
    Icon
    どこまで値をバケツリレーするといいのか?

    articleID ArticleCard コンポーザブルまでリレーしなくていいのか?と思った人もいるかもしれないですね。

    どこまで値をバケツリレーリレーするかどうかはコンポーザブルの責任範囲に関わってきます。今回の実装では ArticleCard は記事情報を表示することと押されたら何らかの処理をすることにのみ責任を持ち、押された際にどんな処理をするかには責任を持ちません。
    これを仮に articleID ArticleCard の引数として受け取る場合、 ArticleCard articleID を使用して遷移するというロジックに責任を持つことになります。

    コンポーザブルがどのような責任を持つかは開発者がプロジェクトごとに決定することであり、一定のルールを決めて運用すると良いでしょう。

  4. 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")
                    }
                )
            }
    
    /* ... */
    MainApp.kt

アプリをビルドして起動してみましょう。記事一覧画面→それぞれの記事詳細 へと遷移できれば成功です。

image block

Icon
省略可能な引数の追加

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を渡す

それでは、記事一覧↔記事詳細への画面遷移と同様に、記事詳細↔コメントの画面遷移を以下のようになるように実装してみましょう!

image block

  1. 記事詳細画面にコメント画面への画面遷移関数を引数で受け取って実装します
    実装例
    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 = {}
            )
        }
    }
    ArticleDetailScreen.kt
  2. 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 = {}
            )
        }
    }
  3. 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()
                    }
                )
            }
        }
    }
    MainApp.kt
  4. コメント画面から記事詳細画面に戻れるようにします
    実装例
    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 = {}
            )
        }
    }
    CommentsScreen.kt
    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()
                    }
                )
            }
        }
    }
    MainApp.kt

5.2 実機で動かしてみよう

画面遷移ができるようになったので、実際に実機で動かしてみましょう!

  1. androidの開発者モードを有効にします
  2. 検証機を用意しているので、PCにUSBで繋げます。
  3. USBでバックを有効にします
  4. Android Studioの実行端末のプルダウンで繋いだ実機を選べるようになるので選択し、実行します。
    image block