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

こんな感じでアニメーションするようになった

ログインするとコメントできます