compose-navigation について
Jetpack Compose アプリで Navigation Compose を使った型安全なナビゲーションを実装するスキルです。Android 開発者向けに、ルート、引数、ディープリンク、ネスト型・アダプティブナビゲーション、テストを扱います。
主な機能
- 01Serializable によるルート定義
- 02NavHost と NavController の設定
- 03引数とディープリンクの処理
- 04ネスト型・アダプティブナビゲーション
- 05ナビゲーションのテストガイド
活用シーン
- 01型安全な画面ルートの構築
- 02画面間での ID の受け渡し
- 03ディープリンクとネスト型フローの追加
- 04Compose ナビゲーションのテスト
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ new-silvermoon/ awesome-android-agent-skills --skill compose-navigationインストール後、Claude Code で/compose-navigationを入力して読み込まれたか確認します。
詳細
Compose Navigation
概要
Navigation Compose ライブラリを使用して、Jetpack Compose アプリケーションに型安全なナビゲーションを実装します。このスキルでは、NavHost の設定、引数の受け渡し、ディープリンク、ネストされたグラフ、アダプティブナビゲーション、テストについて説明します。
セットアップ
Navigation Compose の依存関係を追加します。
// build.gradle.kts
dependencies {
implementation("androidx.navigation:navigation-compose:2.8.5")
// For type-safe navigation (recommended)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
// Enable serialization plugin
plugins {
kotlin("plugin.serialization") version "2.0.21"
}
主な概念
1. ルートを定義する(型安全)
型安全なルートには @Serializable データクラス/オブジェクトを使用します。
import kotlinx.serialization.Serializable
// Simple screen (no arguments)
@Serializable
object Home
// Screen with required argument
@Serializable
data class Profile(val userId: String)
// Screen with optional argument
@Serializable
data class Settings(val section: String? = null)
// Screen with multiple arguments
@Serializable
data class ProductDetail(val productId: String, val showReviews: Boolean = false)
2. NavController を作成する
@Composable
fun MyApp() {
val navController = rememberNavController()
AppNavHost(navController = navController)
}
3. NavHost を作成する
@Composable
fun AppNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Home,
modifier = modifier
) {
composable<Home> {
HomeScreen(
onNavigateToProfile = { userId ->
navController.navigate(Profile(userId))
}
)
}
composable<Profile> { backStackEntry ->
val profile: Profile = backStackEntry.toRoute()
ProfileScreen(userId = profile.userId)
}
composable<Settings> { backStackEntry ->
val settings: Settings = backStackEntry.toRoute()
SettingsScreen(section = settings.section)
}
}
}
ナビゲーションパターン
基本的なナビゲーション
// Navigate forward
navController.navigate(Profile(userId = "user123"))
// Navigate and pop current screen
navController.navigate(Home) {
popUpTo<Home> { inclusive = true }
}
// Navigate back
navController.popBackStack()
// Navigate back to specific destination
navController.popBackStack<Home>(inclusive = false)
オプションを指定して移動する
navController.navigate(Profile(userId = "user123")) {
// Pop up to destination (clear back stack)
popUpTo<Home> {
inclusive = false // Keep Home in stack
saveState = true // Save state of popped screens
}
// Avoid multiple copies of same destination
launchSingleTop = true
// Restore state when navigating to this destination
restoreState = true
}
ボトムナビゲーションパターン
@Composable
fun MainScreen() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
label = { Text("Home") },
selected = currentDestination?.hasRoute<Home>() == true,
onClick = {
navController.navigate(Home) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
// Add more items...
}
}
) { innerPadding ->
AppNavHost(
navController = navController,
modifier = Modifier.padding(innerPadding)
)
}
}
引数の処理
Composable で引数を取得する
composable<Profile> { backStackEntry ->
val profile: Profile = backStackEntry.toRoute()
ProfileScreen(userId = profile.userId)
}
ViewModel で引数を取得する
@HiltViewModel
class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val userRepository: UserRepository
) : ViewModel() {
private val profile: Profile = savedStateHandle.toRoute<Profile>()
val user: StateFlow<User?> = userRepository
.getUser(profile.userId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}
複雑なデータ:オブジェクトではなく ID を渡す
// CORRECT: Pass only the ID
navController.navigate(Profile(userId = "user123"))
// In ViewModel, fetch from repository
class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
private val profile = savedStateHandle.toRoute<Profile>()
val user = userRepository.getUser(profile.userId)
}
// INCORRECT: Don't pass complex objects
// navController.navigate(Profile(user = complexUserObject)) // BAD!
ディープリンク
ディープリンクを定義する
@Serializable
data class Profile(val userId: String)
composable<Profile>(
deepLinks = listOf(
navDeepLink<Profile>(basePath = "https://example.com/profile")
)
) { backStackEntry ->
val profile: Profile = backStackEntry.toRoute()
ProfileScreen(userId = profile.userId)
}
マニフェストの設定
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" />
</intent-filter>
</activity>
通知用の PendingIntent を作成する
val context = LocalContext.current
val deepLinkIntent = Intent(
Intent.ACTION_VIEW,
"https://example.com/profile/user123".toUri(),
context,
MainActivity::class.java
)
val pendingIntent = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(deepLinkIntent)
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
ネストされたナビゲーション
ネストされたグラフを作成する
NavHost(navController = navController, startDestination = Home) {
composable<Home> { HomeScreen() }
// Nested graph for authentication flow
navigation<AuthGraph>(startDestination = Login) {
composable<Login> {
LoginScreen(
onLoginSuccess = {
navController.navigate(Home) {
popUpTo<AuthGraph> { inclusive = true }
}
}
)
}
composable<Register> { RegisterScreen() }
composable<ForgotPassword> { ForgotPasswordScreen() }
}
}
// Route definitions
@Serializable object AuthGraph
@Serializable object Login
@Serializable object Register
@Serializable object ForgotPassword
アダプティブナビゲーション
レスポンシブなナビゲーション(スマートフォンではボトムバー、タブレットではレール)には NavigationSuiteScaffold を使用します。
@Composable
fun AdaptiveApp() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
NavigationSuiteScaffold(
navigationSuiteItems = {
item(
icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
label = { Text("Home") },
selected = currentDestination?.hasRoute<Home>() == true,
onClick = { navController.navigate(Home) }
)
item(
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") },
selected = currentDestination?.hasRoute<Settings>() == true,
onClick = { navController.navigate(Settings()) }
)
}
) {
AppNavHost(navController = navController)
}
}
テスト
セットアップ
// build.gradle.kts
androidTestImplementation("androidx.navigation:navigation-testing:2.8.5")
ナビゲーションをテストする
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var navController: TestNavHostController
@Before
fun setup() {
composeTestRule.setContent {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
AppNavHost(navController = navController)
}
}
@Test
fun verifyStartDestination() {
composeTestRule
.onNodeWithText("Welcome")
.assertIsDisplayed()
}
@Test
fun navigateToProfile_displaysProfileScreen() {
composeTestRule
.onNodeWithText("View Profile")
.performClick()
assertTrue(
navController.currentBackStackEntry?.destination?.hasRoute<Profile>() == true
)
}
}
重要なルール
推奨事項
- 型安全性のため、ルートには
@Serializableを使用します - 引数として渡すのは ID/プリミティブ型のみとします
- ボトムナビゲーションでは
launchSingleTopとpopUpToを使用します - テストしやすいように
NavHostを独立した composable に切り出します - ViewModel では
SavedStateHandle.toRoute<T>()を使用します
禁止事項
- 複雑なオブジェクトをナビゲーション引数として渡さないでください
NavHost内でNavControllerを作成しないでください- 適切なキーを設定せずに
LaunchedEffect内でナビゲーションしないでください - PendingIntent では
FLAG_IMMUTABLEを忘れないでください(Android 12 以降) - 文字列ベースのルート(旧来のパターン)を使用しないでください
参考資料
コメント
Markdown 対応