Jetpack Compose入門 アプリを作る知識-2(一覧から詳細への画面遷移~rememberNavControllerなど)
概要
前回は、一覧画面を作成しました。
今回は、画面遷移を実装します。一覧画面から詳細画面に遷移します。その際に次の画面(詳細画面)に一覧画面から値を渡すことにします。その後、詳細画面の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で画面の中央に表示したい場合は、Column
やRow
の verticalArrangement
や horizontalAlignment
を使います。
画面遷移の処理
画面遷移のための処理を追加します。
@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
上記でも書いたように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 メンバーの発信を集約しています。公式テックブログはこちら→ tech.uzabase.com/archive/category/NewsPicks
Discussion