Open8
Jetpack ComposeとNavigationでTopAppBarがつらい
タイトルの通りJetpack Composeを使いながらNavigationとTopAppBarを組み合わせて使うのがしんどいのでいい感じにしたい。
最終的に自分なりにだいぶ見通しが良くなった気はするがもっといい方法があれば知りたい気持ち。
まずやりたいことは、こういうTopAppBarを持った2つの画面の間をアニメーションを使って遷移したい。
まずは何も考えずにそれぞれの画面でTopAppBarを作って
Content1
@Composable
fun Content1(navController: NavController) {
Scaffold(
topBar = {
TopAppBar(..)
},
) {
// ..
}
}
@Composable
fun Content2(navController: NavController) {
Scaffold(
topBar = {
TopAppBar(..)
},
) {
// ..
}
}
画面間を遷移するようにNavigationを実装
Main
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MdcTheme {
Surface {
val navController = rememberAnimatedNavController()
AnimatedNavHost(
navController = navController,
startDestination = "content1",
exitTransition = {
slideOut { fullSize -> IntOffset(-fullSize.width, 0) }
},
popExitTransition = {
slideOut { fullSize -> IntOffset(fullSize.width, 0) }
},
enterTransition = {
slideIn { fullSize -> IntOffset(fullSize.width, 0) }
},
popEnterTransition = {
slideIn { fullSize -> IntOffset(-fullSize.width, 0) }
}
) {
composable("content1") {
Content1(navController = navController)
}
composable("content2") {
Content2(navController = navController)
}
}
}
}
}
}
}
そうすると当然だけどこうなる。TopAppBarも一緒にアニメーションしてしまう…
なので親の画面にTopAppBarを実装してNavigationではコンテンツ部分だけ遷移させる。
Content
@Composable
fun Content1(
navController: NavController
) {
// content
}
@Composable
fun Content2(
navController: NavController
) {
// content
}
Main
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MdcTheme {
Surface {
val navController = rememberAnimatedNavController()
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "Test app") },
navigationIcon = {
IconButton(onClick = { /*TODO*/ }) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = null
)
}
}
)
},
) {
AnimatedNavHost(
// 省略
) {
composable("content1") {
Content1(navController = navController)
}
composable("content2") {
Content2(navController = navController)
}
}
}
}
}
}
}
}
この実装だけだとちゃんとコンテンツ部分だけアニメーションして遷移できるがTopAppBarの中身が切り替えできないのでTopAppBarをコンテンツ部分に合わせて変わるように実装する必要がある。
が、destinationを文字列で判定して処理を分岐するのが可読性が酷いし実装もつらい…
TopAppBar
val currentBackStack by navController.currentBackStackEntryAsState()
TopAppBar(
title = {
val title = when (currentBackStack?.destination?.route) {
"content1" -> "Test app"
"content2" -> "Second screen"
else -> "Test app"
}
Text(text = title)
},
navigationIcon = {
val icon = when (currentBackStack?.destination?.route) {
"content1" -> Icons.Default.Menu
"content2" -> Icons.Default.ArrowBack
else -> null
}
val onClick: () -> Unit =
when (currentBackStack?.destination?.route) {
"content2" -> {
{ navController.popBackStack("content1", false) }
}
else -> {
{}
}
}
icon?.let {
IconButton(onClick = onClick) {
Icon(imageVector = it, contentDescription = null)
}
}
}
)
なのでこういったsealed interfaceを用意して各画面を定義する。
(実際にはTopAppBarのactionsやsub graph設定用などプロパティはもっと多い)
Screens
sealed interface Screen {
val route: String
val topBarTitle: @Composable () -> Unit
val navigationIcon: @Composable (() -> Unit)?
fun NavGraphBuilder.content(navController: NavController)
}
object Content1 : Screen {
override val route: String = "content1"
override val topBarTitle: @Composable () -> Unit
get() = {
Text(text = "Test app")
}
override val navigationIcon: @Composable (() -> Unit)
get() = {
IconButton(onClick = { }) {
Icon(imageVector = Icons.Default.Menu, contentDescription = null)
}
}
override fun NavGraphBuilder.content(navController: NavController) {
composable(route = this@Content1.route) {
Content1(navController = navController)
}
}
}
object Content2 : Screen {
var onClickTopBarIcon: () -> Unit = {}
override val route: String = "content2"
override val topBarTitle: @Composable () -> Unit
get() = {
Text(text = "Second screen")
}
override val navigationIcon: @Composable (() -> Unit)
get() = {
IconButton(onClick = onClickTopBarIcon) {
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null)
}
}
override fun NavGraphBuilder.content(navController: NavController) {
composable(route = this@Content2.route) {
Content2(navController = navController)
}
}
}
親画面ではこう使う
Main
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MdcTheme {
Surface {
val navController = rememberAnimatedNavController()
val currentBackStack by navController.currentBackStackEntryAsState()
val screens = listOf(
Content1,
Content2.apply {
onClickTopBarIcon = {
navController.popBackStack(Content1.route, false)
}
}
)
Scaffold(
topBar = {
screens
.find { it.route == currentBackStack?.destination?.route }
?.let {
TopAppBar(
title = it.topBarTitle,
navigationIcon = it.navigationIcon
)
}
},
) {
AnimatedNavHost(
navController = navController,
startDestination = Content1.route,
// 省略
) {
composable(Content1.route) {
Content1(navController = navController)
}
composable(Content2.route) {
Content2(navController = navController)
}
}
}
}
}
}
}
}
こんな感じでアニメーションするようになった