Install
$ agentstack add skill-piyushverma0-android-agent-skills-compose-navigation ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Compose Navigation
Type-safe navigation prevents the most common navigation bugs in AI-built apps. These rules cover the complete navigation pattern from setup to --- name: compose-navigation description: | Jetpack Compose Navigation for Android AI agents — complete patterns. Use this skill for any navigation: NavHost, NavController, type-safe @Serializable routes, back stack, deep links, navigation arguments, composable(), nested graphs, shared ViewModels, navigateUp, navigateTopLevel, launchSingleTop, popUpTo, shared element transitions, predictive back gesture, transition animations between screens, bottom navigation, navigation with Hilt, passing arguments, result passing via SavedStateHandle, or any navigation pattern in Compose. Always apply before writing any NavHost or route. ---
Compose Navigation — Complete Guide
Type-safe navigation with shared element transitions and predictive back. These rules cover every pattern from simple routes to complex multi-graph apps.
Setup
[versions]
navigationCompose = "2.8.5"
[libraries]
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
Rule 1: All routes are @Serializable — zero string routes
// navigation/Routes.kt — ALL routes defined here, nowhere else
@Serializable object HomeRoute
@Serializable object SearchRoute
@Serializable object InboxRoute
@Serializable object ProfileRoute
@Serializable object SettingsRoute
// Routes with required arguments
@Serializable data class ItemDetailRoute(val itemId: String)
@Serializable data class EditItemRoute(val itemId: String, val isNew: Boolean = false)
@Serializable data class UserProfileRoute(val userId: String, val fromNotification: Boolean = false)
// Graph containers
@Serializable object AuthGraph
@Serializable object LoginRoute
@Serializable object SignUpRoute
@Serializable object ForgotPasswordRoute
@Serializable object OnboardingGraph
@Serializable object OnboardingStep1Route
@Serializable object OnboardingStep2Route
@Serializable object OnboardingStep3Route
// ❌ NEVER use string routes
navController.navigate("detail/$itemId") // typo-prone, no type safety
composable("detail/{itemId}") { backStackEntry ->
backStackEntry.arguments?.getString("itemId") // nullable, untyped
}
Rule 2: Complete NavHost — every destination registered
// navigation/AppNavHost.kt
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController(),
startDestination: Any = if (isLoggedIn) HomeRoute else AuthGraph
) {
NavHost(
navController = navController,
startDestination = startDestination,
// M3 Expressive enter/exit transitions (applied to ALL screens by default)
enterTransition = { slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(Duration.medium4, easing = AppEasing.EmphasizedDecel)
) + fadeIn(tween(Duration.medium4)) },
exitTransition = { slideOutHorizontally(
targetOffsetX = { -it / 4 },
animationSpec = tween(Duration.short4, easing = AppEasing.EmphasizedAccel)
) + fadeOut(tween(Duration.short4)) },
popEnterTransition = { slideInHorizontally(
initialOffsetX = { -it / 4 },
animationSpec = tween(Duration.medium4, easing = AppEasing.EmphasizedDecel)
) + fadeIn(tween(Duration.medium4)) },
popExitTransition = { slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(Duration.short4, easing = AppEasing.EmphasizedAccel)
) + fadeOut(tween(Duration.short4)) }
) {
// Top-level destinations
composable { HomeScreen(navController = navController) }
composable { SearchScreen(navController = navController) }
composable { InboxScreen(navController = navController) }
composable{ ProfileScreen(navController = navController) }
composable{ SettingsScreen(onBack = { navController.navigateUp() }) }
// Detail screens
composable { entry ->
val route: ItemDetailRoute = entry.toRoute()
ItemDetailScreen(
itemId = route.itemId,
onEdit = { navController.navigate(EditItemRoute(route.itemId)) },
onBack = { navController.navigateUp() }
)
}
composable { entry ->
val route: EditItemRoute = entry.toRoute()
EditItemScreen(
itemId = route.itemId,
isNew = route.isNew,
onDone = { navController.navigateUp() }
)
}
// Auth flow — nested graph
navigation(startDestination = LoginRoute) {
composable {
LoginScreen(
onLoginSuccess = {
navController.navigate(HomeRoute) {
popUpTo(AuthGraph) { inclusive = true } // clear auth stack entirely
}
},
onSignUp = { navController.navigate(SignUpRoute) },
onForgotPass = { navController.navigate(ForgotPasswordRoute) }
)
}
composable {
SignUpScreen(
onSuccess = { navController.navigate(HomeRoute) { popUpTo(AuthGraph) { inclusive = true } } },
onBack = { navController.navigateUp() }
)
}
composable {
ForgotPasswordScreen(onBack = { navController.navigateUp() })
}
}
}
}
Rule 3: Top-level navigation — launchSingleTop prevents duplicate entries
// ✅ Navigation helper for tab/nav items — prevents backstack pollution
fun NavController.navigateTopLevel(route: Any) {
navigate(route) {
// Pop to start destination, saving state for restoration
popUpTo(graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true // don't create duplicate on re-tap
restoreState = true // restore state when re-navigating to tab
}
}
// Usage in NavigationSuiteScaffold
item(
selected = currentDestination?.hasRoute() == true,
onClick = { navController.navigateTopLevel(HomeRoute) }
)
Rule 4: Shared Element Transitions — M3 Expressive
// ✅ Shared element transition between list item and detail screen
// Navigation 2.8+ with material3 1.4+ supports SharedTransitionLayout
@Composable
fun HomeScreen(navController: NavController) {
SharedTransitionLayout {
AnimatedContent(targetState = selectedItem, label = "shared-transition") { item ->
if (item == null) {
LazyColumn {
items(items, key = { it.id }) { item ->
ItemCard(
item = item,
animatedVisibilityScope = this@AnimatedContent,
sharedTransitionScope = this@SharedTransitionLayout,
onClick = { selectedItem = item }
)
}
}
} else {
ItemDetailScreen(
item = item,
animatedVisibilityScope = this@AnimatedContent,
sharedTransitionScope = this@SharedTransitionLayout,
onBack = { selectedItem = null }
)
}
}
}
}
// ✅ In list item — mark shared elements
@Composable
fun ItemCard(
item: Item,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onClick: () -> Unit,
) {
with(sharedTransitionScope) {
Card(onClick = onClick) {
AsyncImage(
model = item.imageUrl,
contentDescription = null,
modifier = Modifier
.sharedElement(
state = rememberSharedContentState("image-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope
)
.fillMaxWidth().aspectRatio(16f / 9f)
)
Text(
item.title,
modifier = Modifier.sharedElement(
state = rememberSharedContentState("title-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope
)
)
}
}
}
// ✅ In detail screen — same shared element keys
@Composable
fun ItemDetailScreen(
item: Item,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onBack: () -> Unit
) {
with(sharedTransitionScope) {
Column {
AsyncImage(
model = item.imageUrl,
contentDescription = item.title,
modifier = Modifier
.sharedElement(
state = rememberSharedContentState("image-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope
)
.fillMaxWidth().aspectRatio(16f / 9f)
)
Text(
item.title,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.sharedElement(
state = rememberSharedContentState("title-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope
).padding(Spacing.md)
)
}
}
}
Rule 5: Deep links with type-safe routes
// ✅ Deep link registered on destination
composable(
deepLinks = listOf(
navDeepLink(basePath = "https://myapp.com/items")
// matches: https://myapp.com/items/{itemId}
)
) { entry ->
val route: ItemDetailRoute = entry.toRoute()
ItemDetailScreen(itemId = route.itemId)
}
// AndroidManifest.xml — intent filter
//
//
//
//
//
//
//
//
Rule 6: Shared ViewModel scoped to NavGraph
// ✅ Share state across multiple screens in a graph
navigation(startDestination = CartRoute) {
composable { entry ->
val parentEntry = remember(entry) { navController.getBackStackEntry(CheckoutGraph) }
val viewModel: CheckoutViewModel = hiltViewModel(parentEntry)
CartScreen(viewModel)
}
composable { entry ->
val parentEntry = remember(entry) { navController.getBackStackEntry(CheckoutGraph) }
val viewModel: CheckoutViewModel = hiltViewModel(parentEntry)
ShippingScreen(viewModel)
}
composable { entry ->
val parentEntry = remember(entry) { navController.getBackStackEntry(CheckoutGraph) }
val viewModel: CheckoutViewModel = hiltViewModel(parentEntry)
PaymentScreen(viewModel)
}
}
Rule 7: Result passing via SavedStateHandle
// ✅ Pass result back to previous destination
// In destination that produces result:
@Composable
fun CreateItemScreen(navController: NavController) {
val onSaved = { newItemId: String ->
navController.previousBackStackEntry
?.savedStateHandle
?.set("new_item_id", newItemId)
navController.navigateUp()
}
}
// In destination that receives result:
@HiltViewModel
class HomeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle
) : ViewModel() {
val newItemId = savedStateHandle.getStateFlow("new_item_id", null)
}
Rule 8: Predictive back — enable in manifest
// ✅ NavHost 2.8+ handles predictive back automatically for type-safe routes
// ✅ Custom back handler with animation for non-NavHost back
BackHandler {
coroutineScope.launch {
// Animate out before popping
isVisible = false
delay(Duration.short4.toLong())
navController.navigateUp()
}
}
Common Mistakes
❌ String routes anywhere — use @Serializable object/data class ❌ navController.navigate(route) for tabs — use navigateTopLevel() ❌ navController.navigate(SomeRoute) { popUpTo(0) } — pops past graph root; use graph.findStartDestination().id ❌ hiltViewModel() in every screen that shares state — scope to NavGraph entry ❌ Missing popUpTo(AuthGraph) { inclusive = true } after login — user can press Back to login ❌ Passing complex objects as route args — pass only IDs, fetch in ViewModel ❌ SharedElement keys not matching between list and detail — transition won't animate ❌ No screen-level transitions in NavHost — add enter/exit/pop specs at NavHost leveldeep links.
Setup
# libs.versions.toml
navigationCompose = "2.8.3"
[libraries]
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
Rule 1: Type-safe routes with @Serializable
// ✅ Define all routes as @Serializable objects/data classes
// navigation/Routes.kt
@Serializable
object HomeRoute
@Serializable
object SearchRoute
@Serializable
data class ItemDetailRoute(val itemId: String)
@Serializable
data class EditItemRoute(val itemId: String, val isNew: Boolean = false)
@Serializable
object SettingsRoute
// ❌ String routes — typos compile, arguments are untyped
navController.navigate("detail/$itemId") // typo-prone
composable("detail/{itemId}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("itemId") // nullable, untyped
}
Rule 2: NavHost setup
// ✅ Complete NavHost with type-safe destinations
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController(),
startDestination: Any = HomeRoute
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
composable {
HomeScreen(
onItemClick = { itemId ->
navController.navigate(ItemDetailRoute(itemId))
},
onSearchClick = { navController.navigate(SearchRoute) }
)
}
composable {
SearchScreen(
onItemClick = { itemId ->
navController.navigate(ItemDetailRoute(itemId))
},
onBackClick = { navController.navigateUp() }
)
}
composable { backStackEntry ->
val route: ItemDetailRoute = backStackEntry.toRoute()
ItemDetailScreen(
itemId = route.itemId,
onEditClick = { navController.navigate(EditItemRoute(route.itemId)) },
onBackClick = { navController.navigateUp() }
)
}
composable { backStackEntry ->
val route: EditItemRoute = backStackEntry.toRoute()
EditItemScreen(
itemId = route.itemId,
isNew = route.isNew,
onSaved = { navController.navigateUp() },
onCancel = { navController.navigateUp() }
)
}
composable {
SettingsScreen(onBackClick = { navController.navigateUp() })
}
}
}
Rule 3: Navigate with launchSingleTop for tabs
// ✅ Tab navigation — prevent duplicate destinations on backstack
fun NavController.navigateToTopLevel(route: Any) {
navigate(route) {
popUpTo(graph.findStart
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [piyushverma0](https://github.com/piyushverma0)
- **Source:** [piyushverma0/android-agent-skills](https://github.com/piyushverma0/android-agent-skills)
- **License:** MIT
- **Homepage:** https://android-agent-skills.vercel.app
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.