この記事について 🦔
- KotlinとJetpack Composeで「Xみたいなナビゲーション」を持ったAndroidアプリの実装例を紹介します
- Kotlinの文法やJetpack Compose自体に関する詳しい説明はありません
- 実装のエッセンスは記事中で言及していますが、省略箇所も多いので、コピペの際は注意してください
- 紹介する方法は、必ずしもベストソリューション or ベストプラクティスとは限りません、もっと良い方法があれば教えてくれると喜びます!
- ソースコードはこちら
- Flutterバージョンも投稿予定です…
Xみたいなナビゲーションとは 🐝
ここで「Xみたいなナビゲーション」とは、トップのGif画像の様なものを指しています。詳しくは、以下の様な機能を持っているものです
- ログイン, メイン画面 , 設定画面などのメインの画面遷移がある
- 画面下のタブバーによる画面切り替えがある
- 各画面にステート (動的な要素) があり、タブ操作時にも保持されている
-
タブ内に画面遷移があり…
4-1. routeと画面スタックが動的 (操作した時に次のrouteが決定し、さらに画面数が無制限)
4-2. 遷移時にTopAppBar
などの状態が更新される
Amazonのアプリでも同様の機能が実装されていますが、特に4つ目の要件は実装が意外とムズかしく、使うフレームワークによって実装方法がかなり異なるため、最初は少し苦戦しました…
開発環境 🐥
compileSdk = 34
jdk = 23
kotlin = "2.0.0"
composeBom = "2024.04.01"
navigationCompose = "2.8.3"
Navigation Compose
のインストールは方法こちら
画面一覧
-
ログイン画面 (
LoginScreen
):ダミーログイン画面です -
メイン画面 (
MainScreen
):下にタブバーがあり、それぞれ次の画面に対応しています-
インクリメント画面 (
IncrementScreen
):ボタンを押した回数をカウントします -
ナビゲーション画面 (
NavigationScreen
):ボタンを押すと次々と画面遷移します
-
インクリメント画面 (
-
設定画面 (
SettingScreen
):ダミー設定画面です
1. メインの画面遷移について 🦆
ログイン > メイン > 設定画面間の遷移は基本的には至ってシンプルな実装ですが、ポイントはAppNavHost
内のcomposable()
で毎回sharedViewModel.navigate()
を呼んでいる点です。
これによりsharedViewModel
に「今どの画面を表示しているか」を格納し、TopAppBar
のタイトルを更新します。
(同じ様な処理を書くならcomposable()
をラップしたメソッドを定義した方がイイとは思っていますよ、はい!)
(そもそも各画面に一つずつTopAppBar
を持たせて直接タイトルを渡せば、このようなViewModel
の実装は必要ありません。今回は実験的な試みという事で!)
// アプリ内で共有のUiStateとViewModel
data class SharedUiState(
val currentScreen: AppDestination = AppScreens.Login,
)
class SharedViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SharedUiState())
val uiState: StateFlow<SharedUiState> = _uiState
fun navigate(screen: AppDestination) = _uiState.update {
it.copy(currentScreen = screen)
}
}
// 画面クラスインターフェース
interface AppDestination {
val title: String
val route: String
}
// メインの画面リスト
sealed class AppScreens(
override val title: String,
override val route: String
) : AppDestination {
object Login : AppScreens("ログイン", "login")
object Main : AppScreens("メイン", "main")
object Setting : AppScreens("設定", "setting")
}
// メインのナビホスト
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController(),
sharedViewModel: SharedViewModel = viewModel()
) {
NavHost(
navController = navController,
startDestination = AppScreens.Login.route,
) {
composable(
route = AppScreens.Login.route,
) { backStackEntry ->
sharedViewModel.navigate(AppScreens.Login)
LoginScreen(navController)
}
composable(
route = AppScreens.Main.route,
) { backStackEntry ->
sharedViewModel.navigate(AppScreens.Main)
MainScreen(navController)
}
composable(
route = AppScreens.Setting.route,
) { backStackEntry ->
sharedViewModel.navigate(AppScreens.Setting)
SettingScreen(navController)
}
}
}
LoginScreen
とSettingScreen
は画面遷移させる為だけにある空っぽビューなので割愛します。(ソースはこちら)
2.SharedViewModel
をアプリ内でシェアする方法について 🦦
通常、上記の実装だけではsharedViewModel
のスコープはAppNavHost
内になるので、sharedViewModel.navigate()
によってsharedUiState
を更新しても、最新の状態は他のビューから参照することができません。
そこで、ひとつ上のMainActivity
でCompositionLocalProvider
を使って、AppNavHost
以下のどの画面からでも単一のshareViewModel
へアクセス出来るようにします。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val localSharedViewModel = compositionLocalOf<SharedViewModel> {
error("SharedViewModel not provided")
}
CompositionLocalProvider(
localSharedViewModel provides viewModel(factory = ViewModelProvider.Factory)
) {
ComposeNavigationSampleTheme {
AppNavHost()
}
}
}
}
}
3. インクリメント画面の実装 🦥
IncrementScreen
にはボタンがあり、タップした回数が表示されるだけの画面です。
Flutterのアプリ新規作成すると現れるアレとほぼ同じです。とてもシンプルなので説明は割愛します。
// UiState
data class IncrementScreenUiState(
val count: Int = 0 // ボタンタップ回数
)
// ViewModel
class IncrementScreenViewModel : ViewModel() {
private val _uiState = MutableStateFlow(IncrementScreenUiState())
val uiState: StateFlow<IncrementScreenUiState> = _uiState
fun increment() = _uiState.update {
it.copy(it.count + 1)
}
}
// ビュー
@Composable
fun IncrementScreen(
modifier: Modifier = Modifier,
viewModel: IncrementScreenViewModel = viewModel(),
) {
val uiState = viewModel.uiState.collectAsState()
Scaffold(
modifier = modifier
) { safeInsets ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(safeInsets)
.fillMaxSize()
) {
// テキスト
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1F)
) {
Text(
uiState.value.count.toString() + " クリック",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
// インクリメントボタン
FloatingActionButton(
onClick = { viewModel.increment() },
) {
Icon(Icons.Default.Add, "インクリメントボタン")
}
}
}
}
今回のアプリではインクリメント画面内で遷移はありませんが、表示時にsharedViewModel.navigate()
を実行するのにわかりやすいため、AppNavHost
と似た形でIncrementScreenNavHost
を作成します。
sealed class IncrementScreens(
override val title: String,
override val route: String
) : AppDestination {
object Index : IncrementScreens("インクリメント", "increment/index")
}
@Composable
fun IncrementScreenNavHost(
navController: NavHostController,
sharedViewModel: SharedViewModel = viewModel(),
) {
NavHost(
navController = navController,
startDestination = IncrementScreens.Index.route,
) {
composable(
route = IncrementScreens.Index.route,
) { backStackEntry ->
sharedViewModel.navigate(IncrementScreens.Index)
IncrementScreen()
}
}
}
ここまではとてもシンプルでしたね!
ちなみにこの「メインのNavHost
とは別のNavHost
を作る」のが、今回のミソです。
4. ナビゲーション画面の実装 🦭
さて、問題のナビゲーション画面です。ここでは画面内のボタンをタップするたびに新しい画面に遷移します。そして遷移のたびにTopAppBar
が更新され、タイトルと戻るボタンの表示が適宜切り替わります。
@Composable
fun NavigatorScreen(
count: Int,
navController: NavController, // AppNavHostのNavControllerとは異なる点に注意
modifier: Modifier = Modifier
) {
Scaffold(
modifier = modifier
) { safeInsets ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(safeInsets)
.fillMaxSize()
) {
// テキスト
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1F)
) {
Text(
NavigatorScreens.Page(count).title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
// 次のページボタン
Button(
onClick = {
val nextPage = NavigatorScreens.Page(count + 1)
navController.navigate(nextPage.route)
}
) {
Text("次のページ")
}
}
}
}
ご覧の通りですが、NavigatorScreen
は実はシンプルで、基本的には受け取ったcount: Int
を表示するだけの画面です。ボタンをタップするとnavController.navigate()
で新たなNavigatorScreen
へと再起的に (使い方合ってる?) 遷移します。
ただしこの時、navController
にAppNavHost
と同じNavController
を渡すことはできません。「Xみたい」なナビゲーションするためには、タブ下の画面スタックはまた別で管理しないといけないからです。
sealed class NavigatorScreens(
override val title: String,
override val route: String
) : AppDestination {
object Index : NavigatorScreens("ページ 1", "$navigatorScreenRoute/index")
data class Page(val count: Int) : NavigatorScreens(
"ページ $count ",
navigatorScreen(count)
)
}
// routeの共通部分
private val navigatorScreensRoute
get() = "navigator"
// arguments付きroute
private fun navigatorScreensRouteWithCount(count: Int): String {
return navigatorScreensRoute + "?count=${count.toString()}"
}
// arguments付きrouteのフォーマット
private val navigatorScreensRouteFormat
get() = "$navigatorScreensRoute?count={count}"
@Composable
fun NavigatorScreenNavHost(
navController: NavHostController, // NavigatorScreen用のNavController
sharedViewModel: SharedViewModel = viewModel(),
) {
NavHost(
navController = navController,
startDestination = NavigatorScreens.Index.route,
) {
// 1ページ目
composable(
route = NavigatorScreens.Index.route,
) { backStackEntry ->
sharedViewModel.navigate(NavigatorScreens.Page(1))
NavigatorScreen(1, navController)
}
// 1または2ページ目以降
composable(
route = navigatorScreenComposableRoute,
) { backStackEntry ->
val count = backStackEntry.arguments?.getString("count")!!.toInt()
sharedViewModel.navigate(NavigatorScreens.Page(count))
NavigatorScreen(count, navController)
}
}
}
private
メソッドはいずれもroute
用のString
を生成するための便利メソッドです。
NavigatorScreens
にはIndex
以外にPage(val count: Int)
を設けて、引数から動的に生成できる様にしています。
NavigatorScreenNavHost
内の2つ目のcomposable()
はNavigatorScreens
のroute
からページ数を動的に決定し、NavigatorScreen
を生成します。この実装によって、いくらでも次の画面がある、今時のSNSっぽい画面遷移ができるわけです。
ここでも毎回sharedViewModel.navigate()
を実行しています。
5. メイン画面の実装 🦫
メイン画面を実装して、ここまでに実装した2つの画面をタブでポチポチ切り替えられるようにします。
IncrementScreen
とNavigatorScreen
それぞれのNavController
をMainScreen
に生成させています。これにより (MainScreen
が破棄されない限り) それぞれのタブ下の画面スタックを保持する機能が実現します。
@Composable
fun MainScreen(
globalNavController: NavController,
modifier: Modifier = Modifier,
viewModel: MainScreenViewModel = viewModel(),
sharedViewModel: SharedViewModel = viewModel(),
incrementScreenNavController: NavHostController = rememberNavController(),
navigatorScreenNavController: NavHostController = rememberNavController(),
) {
val uiState = viewModel.uiState.collectAsState()
val sharedUiState = sharedViewModel.uiState.collectAsState()
// 表示中の画面を管理しているのNavController
fun currentScreenNavController(): NavController {
return if (uiState.value.selectedItem is IncrementScreens) {
incrementScreenNavController
} else {
navigatorScreenNavController
}
}
Scaffold(
topBar = {
MainScreenTopAppBar(
title = sharedUiState.value.currentScreen.title,
navController = currentScreenNavController(),
onClickSetting = {
globalNavController.navigate(AppScreens.Setting.route)
}
)
},
bottomBar = {
MainScreenBottomAppBar(
selectedItem = uiState.value.selectedItem,
onClick = { destination ->
if (destination == uiState.value.selectedItem) {
if (destination is NavigatorScreens) {
navigatorScreenNavController.popBackStack(NavigatorScreens.Index.route, false)
}
} else {
viewModel.selectBottomBarItem(destination)
}
}
)
},
modifier = modifier
) {safeInsets ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(safeInsets)
) {
when (uiState.value.selectedItem) {
is IncrementScreens -> IncrementScreenNavHost(incrementScreenNavController)
is NavigatorScreens -> NavigatorScreenNavHost(navigatorScreenNavController)
}
}
}
}
MainScreenTopAppBar
はカスタムビューで、sharedViewModel
から表示中の画面を取得し、また戻る画面がある時にのみ戻るボタンを表示します。(実装はこちら)
MainScreenBottomAppBar
もまたカスタムビューで、選択中のタブをもう一度タップすると、戻る画面がある時にのみ最初のページに戻ります。(つまり「Xみたい」な動きです)
それぞれ詳しい実装が気になる方はソースコードをご覧ください。
完成 🐛
細かな実装が気になる方はソースコードをご覧ください。シュミレーターでも動くのでお試しあれ。
XやAmazonなどのアプリ以外で、今回の様なタブ切り替えや画面遷移をするケースは多くないかもしれませんが、Kotlinのナビゲーションは実装の自由度が高く、やりたい事が比較的すぐに実現できました。
Flutterでも同じことをしようとすると意外と大変だったのですが、近日中にそちらも投稿しようと思いますので、お楽しみに!