💬

Jetpack Compose入門 アプリを作る知識-2(一覧から詳細への画面遷移~rememberNavControllerなど)

2022/03/28に公開

概要

前回は、一覧画面を作成しました。
今回は、画面遷移を実装します。一覧画面から詳細画面に遷移します。その際に次の画面(詳細画面)に一覧画面から値を渡すことにします。その後、詳細画面のTopBarに戻るボタンがあり、一覧画面に戻れるようにします。よくあるやつです。
ついでにComposeでのモーダル画面の作り方も紹介しようと思います。

作成アプリ

こんな画面とこれのモーダルバージョンを作ります。

画面遷移のためのライブラリ

まずは、画面遷移のためにbuild.gradleに以下を追加します

+    implementation "androidx.navigation:navigation-compose:2.4.1"

詳細画面(次の画面)

次の画面である詳細画面を作成します。

@Composable
private fun Detail(
  index: Int,
  onClick: () -> Unit,
) {
  Scaffold(
    topBar = {
      TopAppBar(
        title = { Text("Detail") },
        navigationIcon = {
          IconButton(onClick = onClick) {
            Icon(
              imageVector = Icons.Default.ArrowBack,
              contentDescription = "go back"
            )
          }
        },
      )
    }
  ) {
    Column(
      modifier = Modifier.fillMaxSize(),
      verticalArrangement = Arrangement.Center,
      horizontalAlignment = Alignment.CenterHorizontally,
    ) {
      Text(
        text = "詳細画面 index=${index+1}"
      )
    }
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun DetailDefaultPreview() {
  SampleComposeTheme {
    Detail(100) {}
  }
}

こんな画面になります。

引数のindexは、画面遷移時に前の画面から渡される一覧のindex値です。
引数のonClickは、戻るボタンで戻る処理が渡されます。
IconButtonのonClick時に呼び出されます。呼び出し元で戻るための処理が記述されています。
Icons.Default.ArrowBack がAndroidで使われる戻るのアイコンです。

        navigationIcon = {
          IconButton(onClick = onClick) {
            Icon(
              imageVector = Icons.Default.ArrowBack,
              contentDescription = "go back"
            )
          }
        },

画面遷移とは関係ないですが、Composeで画面の中央に表示したい場合は、ColumnRowverticalArrangementhorizontalAlignment を使います。

画面遷移の処理

画面遷移のための処理を追加します。

@Composable
fun MyContentView() {
  val mocks = (1..100).map {
    Pair("This is a title$it", "Detail Content$it")
  }
+  val navController = rememberNavController()
+  NavHost(navController = navController, startDestination = "main") {
+    composable("main") {
      ListTitles(contents = mocks) { index ->
+        navController.navigate("second/$index")
      }
+    }
+    composable(
+      "second/{index}",
+      arguments = listOf(navArgument("index") { type = NavType.IntType })
+    ) { backStackEntry ->
+      Detail(backStackEntry.arguments?.getInt("index") ?: 1) {
+        navController.navigateUp()
+      }
+    }
+  }
}

rememberNavController

コンポーザブル関数は、以前書いたように、副作用がなく冪等です。
その場合に変更する状態を扱うためにどう実現するかというと、JetPack Composeでは、状態を扱う仕組みとして、remember()コンポーザブル関数やrememberSaveable()コンポーザブル関数を用意しています。
いわゆるステートフルなコンポーザブル関数を作りたい場合は、関数内でこれらの関数を使います。

JetPack Composeでは画面遷移も状態として扱います。Androidの画面は遷移するたびにスタックされていると考えてください。
基本的には画面遷移するたびに上のリストに積まれ、戻るとリストの上から削除されます。
スタックの状態は以下です。


  • 起動すると
  • 一覧画面
    詳細画面に遷移すると
  • 詳細画面
  • 一覧画面
    になり、戻ると
  • 一覧画面
    になるイメージです。

画面遷移時では必ず利用するであろうrememberNavController()コンポーザブル関数は、内部でrememberSaveable()を呼び出しています。

このサンプルの場合は、rememberNavController()コンポーザブル関数では、ComposeNavigator()コンポーザブル関数をインスタンスしており、

@Composable
public fun rememberNavController(
    vararg navigators: Navigator<out NavDestination>
): NavHostController {
    val context = LocalContext.current
    return rememberSaveable(inputs = navigators, saver = NavControllerSaver(context)) {
        createNavController(context)
    }.apply {
        for (navigator in navigators) {
            navigatorProvider.addNavigator(navigator)
        }
    }
}

private fun createNavController(context: Context) =
    NavHostController(context).apply {
        navigatorProvider.addNavigator(ComposeNavigator())
        navigatorProvider.addNavigator(DialogNavigator())
    }

そこで、NavigatorStateクラスを保持しています。

public class ComposeNavigator : Navigator<Destination>() {
    internal val backStack get() = state.backStack
・・・
}
public abstract class Navigator<D : NavDestination> {
    private var _state: NavigatorState? 
・・・    

NavigatorStateクラスは、NavBackStackEntryクラスをStateFlowとして保持しています。

public abstract class NavigatorState {
    ・・・
    private val _backStack: MutableStateFlow<List<NavBackStackEntry>> = MutableStateFlow(listOf())
    ・・・

最終的には、この_backStackは、以下のようにNavHost()コンポーザブル関数のComposableでの監視対象のStateクラスとして変換されています。

@Composable
public fun NavHost(
    navController: NavHostController,
    graph: NavGraph,
    modifier: Modifier = Modifier
) {
・・・
    val backStack by composeNavigator.backStack.collectAsState()
・・・
}

つまり、この値が変わると監視している処理が動き出す=再コンポーザブルされる=画面遷移が行われるということです。
ちなみにNavBackStackEntryクラスは、Compose Navigationのクラスであり、スタックされたひとつの画面を表します。

上記でも書いたようにNavHostがスタックの状態を監視しています。
NavGraphBuilderクラスの拡張関数であるcomposable()の第一引数で、画面のコンポーザブルと文字を一意に管理しています。

NavHost(navController = navController, startDestination = "main") {
    composable("main") {
      ListTitles(contents = mocks) { index ->
        navController.navigate("second/$index")
      }
    }

上の場合は、最初の画面はmainで、それは、ListTitles()コンポーザブル関数であることを表しています。ListTitlesの最後の引数は、一覧の一つのアイテムをタップしたときに呼ばれる処理を記述しています。この場合は、second/$indexに遷移することを表しています。
second/$indexは次の行に定義が書かれています。

    composable(
      "second/{index}",
      arguments = listOf(navArgument("index") { type = NavType.IntType })
    ) { backStackEntry ->
      Detail(backStackEntry.arguments?.getInt("index") ?: 1) {
        navController.navigateUp()
      }
    }

{index}部分は、前の画面から渡されるパラメーターで、それがarguments引数によって表されています。
この値は、backStackEntry.arguments?.getInt("index")で取得できます。

それをDetail()コンポーザブル関数で渡しています。Detail()の最後の引数は、戻るボタンを押下したら呼ばれる処理です。

画面遷移の動作はざっくりと書くとこんな感じです。経験者からすると簡単な処理に思えますが、Android未経験者には前提状態として知っておくことがいっぱいありすぎて難しいかなと思います。

モーダル遷移

最後についでに書いておきます。あまり、このことを書いている記事がなかったので、まあまあ役立つかもと思ってます。
Flutterでは、MaterialPageRouteウィジェットでfullscreenDialog: trueにするだけで解決できましたが、おそらくComposeでは結構記述しないといけないと思います。(良い方法があれば教えてください)

こんな画面です。

マテリアルコンポーネントを追加しておきます。

+    implementation 'com.google.android.material:material:1.5.0'

次にテーマを追記します。

themes.xml

<item name="android:dialogTheme">@style/Theme.DialogFullScreen</item>
・・・
+        <item name="android:dialogTheme">@style/Theme.DialogFullScreen</item>
     </style>parent="@style/ThemeOverlay.MaterialComponents.Dialog.Alert">
・・・
+        <item name="android:windowMinWidthMajor">100%</item>
+        <item name="android:windowMinWidthMinor">100%</item>
+    </style>

でこんなコンポーザブルを用意すれば完成です。

@Composable
fun FullScreenDialog(
  index: Int,
  onClick: () -> Unit,
) {
  Dialog(onDismissRequest = onClick) {
    Surface(
      modifier = Modifier.fillMaxSize(),
      shape = RoundedCornerShape(0.dp),
      color = Color.White
    ) {
      Box(
        contentAlignment = Alignment.Center
      ) {
        Scaffold(
          topBar = {
            TopAppBar(
              title = { Text("My TopAppBar") },
              navigationIcon = {
                IconButton(onClick = onClick) {
                  Icon(Icons.Outlined.Close, contentDescription = "Close")
                }
              },
            )
          }) {
          Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,
          ) {
            Text(
              text = "詳細画面 index=${index+1}"
            )
          }
        }
      }
    }
  }
}

全体のソース

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      SampleComposeTheme {
        Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
          MyContentView()
        }
      }
    }
  }
}

@Composable
fun MyContentView() {

  val mocks = (1..100).map {
    Pair("This is a title$it", "Detail Content$it")
  }
  val navController = rememberNavController()
  NavHost(navController = navController, startDestination = "main") {
    composable("main") {
      ListTitles(contents = mocks) { index ->
        navController.navigate("second/$index")
      }
    }
    composable(
      "second/{index}",
      arguments = listOf(navArgument("index") { type = NavType.IntType })
    ) { backStackEntry ->
      FullScreenDialog(backStackEntry.arguments?.getInt("index") ?: 1) {
        navController.navigateUp()
      }
    }
  }
}

@Composable
private fun ListTitles(contents: List<Pair<String, String>>, onClick: (Int) -> Unit) {
  Scaffold(
    topBar = {
      TopAppBar(
        title = { Text("My TopAppBar") },
      )
    }
  ) {
    LazyColumn {
      items(contents.size) { index ->
        val content = contents[index]
        ListTitle(title = content.first, body = content.second, index=index,onClick)
      }
    }
  }
}

@Composable
private fun ListTitle(
  title: String,
  body: String,
  index:Int,
  onClick: (Int) -> Unit,
) {
  Surface(
    modifier = Modifier.clickable { onClick(index) },
    shape = MaterialTheme.shapes.medium, elevation = 1.dp,
  ) {
    Row(
      verticalAlignment = Alignment.CenterVertically,
      modifier = Modifier.padding(all = 8.dp)
    ) {
      Image(
        painter = painterResource(R.drawable.ic_launcher_background),
        contentDescription = "Contact profile picture",
        modifier = Modifier
          .size(40.dp)
          .clip(CircleShape)
          .border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
      )

      Column(
        modifier = Modifier
          .fillMaxWidth()
          .padding(horizontal = 8.dp, vertical = 16.dp),
      ) {
        Text(
          text = title,
          style = MaterialTheme.typography.subtitle2,
        )
        Spacer(modifier = Modifier.height(4.dp))
        Text(
          text = body,
          style = MaterialTheme.typography.body2,
        )
      }
    }
  }
}

@Composable
private fun Detail(
  index: Int,
  onClick: () -> Unit,
) {
  Scaffold(
    topBar = {
      TopAppBar(
        title = { Text("Detail") },
        navigationIcon = {
          IconButton(onClick = onClick) {
            Icon(
              imageVector = Icons.Default.ArrowBack,
              contentDescription = "go back"
            )
          }
        },
      )
    }
  ) {
    Column(
      modifier = Modifier.fillMaxSize(),
      verticalArrangement = Arrangement.Center,
      horizontalAlignment = Alignment.CenterHorizontally,
    ) {
      Text(
        text = "詳細画面 index=${index+1}"
      )
    }
  }
}

@Composable
fun FullScreenDialog(
  index: Int,
  onClick: () -> Unit,
) {
  Dialog(onDismissRequest = onClick) {
    Surface(
      modifier = Modifier.fillMaxSize(),
      shape = RoundedCornerShape(0.dp),
      color = Color.White
    ) {
      Box(
        contentAlignment = Alignment.Center
      ) {
        Scaffold(
          topBar = {
            TopAppBar(
              title = { Text("My TopAppBar") },
              navigationIcon = {
                IconButton(onClick = onClick) {
                  Icon(Icons.Outlined.Close, contentDescription = "Close")
                }
              },
            )
          }) {
          Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,
          ) {
            Text(
              text = "詳細画面 index=${index+1}"
            )
          }
        }
      }
    }
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_NO,
  showBackground = true
)
@Composable
fun DefaultPreview() {
  SampleComposeTheme {
    MyContentView()
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun MyContentViewDarkPreview() {
  SampleComposeTheme {
    MyContentView()
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun ListTitlesDarkPreview() {
  SampleComposeTheme {
    val mocks = (1..100).map {
      Pair("This is a title$it", "Detail Content$it")
    }
    ListTitles(contents = mocks, {})
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun ListTitleDarkPreview() {
  SampleComposeTheme {
    ListTitle("This is a title", "Detail Content", index=100,{})
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun DetailDefaultPreview() {
  SampleComposeTheme {
    Detail(100) {}
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun FullScreenDialogDefaultPreview() {
  SampleComposeTheme {
    FullScreenDialog(100) {}
  }
}

NewsPicks の Zenn

Discussion