0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Kotlin Jetpack ComposeでX (エックス) みたいなナビゲーションを実装する

Last updated at Posted at 2024-12-08
3c8de213-ce98-9ddf-35c3-98541fdbce94.gif

この記事について 🦔

  • KotlinJetpack Composeで「Xみたいなナビゲーション」を持ったAndroidアプリの実装例を紹介します
  • Kotlinの文法やJetpack Compose自体に関する詳しい説明はありません
  • 実装のエッセンスは記事中で言及していますが、省略箇所も多いので、コピペの際は注意してください
  • 紹介する方法は、必ずしもベストソリューション or ベストプラクティスとは限りません、もっと良い方法があれば教えてくれると喜びます!
  • ソースコードはこちら
  • Flutterバージョンも投稿予定です…

Xみたいなナビゲーションとは 🐝

ここで「Xみたいなナビゲーション」とは、トップのGif画像の様なものを指しています。詳しくは、以下の様な機能を持っているものです

  1. ログイン, メイン画面 , 設定画面などのメインの画面遷移がある
  2. 画面下のタブバーによる画面切り替えがある
  3. 各画面にステート (動的な要素) があり、タブ操作時にも保持されている
  4. タブ内画面遷移があり…
    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のインストールは方法こちら

画面一覧

all.jpg

  • ログイン画面 (LoginScreen):ダミーログイン画面です
  • メイン画面 (MainScreen):下にタブバーがあり、それぞれ次の画面に対応しています
    • インクリメント画面 (IncrementScreen):ボタンを押した回数をカウントします
    • ナビゲーション画面 (NavigationScreen):ボタンを押すと次々と画面遷移します
  • 設定画面 (SettingScreen):ダミー設定画面です

1. メインの画面遷移について 🦆

ログイン > メイン > 設定画面間の遷移は基本的には至ってシンプルな実装ですが、ポイントはAppNavHost内のcomposable()で毎回sharedViewModel.navigate()を呼んでいる点です。
これによりsharedViewModelに「今どの画面を表示しているか」を格納し、TopAppBarのタイトルを更新します。

(同じ様な処理を書くならcomposable()をラップしたメソッドを定義した方がイイとは思っていますよ、はい!)
(そもそも各画面に一つずつTopAppBarを持たせて直接タイトルを渡せば、このようなViewModelの実装は必要ありません。今回は実験的な試みという事で!)

ui/SharedViewModel.kt
// アプリ内で共有の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)
    }
}
ui/AppNavHost.kt
// 画面クラスインターフェース
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)
        }
    }
}

LoginScreenSettingScreenは画面遷移させる為だけにある空っぽビューなので割愛します。(ソースはこちら)

2.SharedViewModelをアプリ内でシェアする方法について 🦦

通常、上記の実装だけではsharedViewModelのスコープはAppNavHost内になるので、sharedViewModel.navigate()によってsharedUiStateを更新しても、最新の状態は他のビューから参照することができません。

そこで、ひとつ上のMainActivityCompositionLocalProviderを使って、AppNavHost以下のどの画面からでも単一のshareViewModelへアクセス出来るようにします。

ui/MainActivity.kt
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. インクリメント画面の実装 🦥

adc00f62-d390-5439-cd00-5137ad57f886.jpeg

IncrementScreenにはボタンがあり、タップした回数が表示されるだけの画面です。
Flutterのアプリ新規作成すると現れるアレとほぼ同じです。とてもシンプルなので説明は割愛します。

ui/incrementScreen/IncrementScreen.kt
// 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を作成します。

ui/MainScreenNavHost.kt
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. ナビゲーション画面の実装 🦭

3558a4b5-115c-8f15-c3d2-d9b56088e7cf.jpeg

さて、問題のナビゲーション画面です。ここでは画面内のボタンをタップするたびに新しい画面に遷移します。そして遷移のたびにTopAppBarが更新され、タイトルと戻るボタンの表示が適宜切り替わります。

ui/navigatorScreen/NavigatorScreen.kt
@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へと再起的に (使い方合ってる?) 遷移します。

ただしこの時、navControllerAppNavHostと同じNavControllerを渡すことはできません。「Xみたい」なナビゲーションするためには、タブ下の画面スタックはまた別で管理しないといけないからです。

ui/MainScreenNavHost.kt
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()NavigatorScreensrouteからページ数を動的に決定し、NavigatorScreenを生成します。この実装によって、いくらでも次の画面がある、今時のSNSっぽい画面遷移ができるわけです。

ここでも毎回sharedViewModel.navigate()を実行しています。

5. メイン画面の実装 🦫

5d12ff19-aafb-b6d3-07c6-cc21348ab164.jpeg

メイン画面を実装して、ここまでに実装した2つの画面をタブでポチポチ切り替えられるようにします。

IncrementScreenNavigatorScreenそれぞれのNavControllerMainScreenに生成させています。これにより (MainScreenが破棄されない限り) それぞれのタブ下の画面スタックを保持する機能が実現します。

ui/mainScreen/MainScreen.kt
@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みたい」な動きです)

それぞれ詳しい実装が気になる方はソースコードをご覧ください。

完成 🐛

3c8de213-ce98-9ddf-35c3-98541fdbce94.gif

細かな実装が気になる方はソースコードをご覧ください。シュミレーターでも動くのでお試しあれ。

XやAmazonなどのアプリ以外で、今回の様なタブ切り替えや画面遷移をするケースは多くないかもしれませんが、Kotlinのナビゲーションは実装の自由度が高く、やりたい事が比較的すぐに実現できました。
Flutterでも同じことをしようとすると意外と大変だったのですが、近日中にそちらも投稿しようと思いますので、お楽しみに!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?