Navigating With Parcelable Arguments in Jetpack Compose
Use the Navigation compose in your Android apps

The Android team announced that using a Parcelable
to transfer arguments between screens is an antipattern. But many are not ready to give up yet…and it is not convenient to save all the data before displaying it on another screen.
Today you will learn a simple way to pass arguments using Jetpack Compose navigation library.
To begin with, let’s look at what Google offers us for navigating arguments. A solution with enough boilerplate code, but we have to compare it with my solution.
First, you need to add navigation library to the project:
dependencies {
def nav_version = "2.5.3"
implementation("androidx.navigation:navigation-compose:$nav_version")
}
After this, we should create our navigation graph, where NavController
is the central API to control your navigation stack.
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "profile") {
composable("profile") { Profile(/*...*/) }
composable("friendslist") { FriendsList(/*...*/) }
/*...*/
}
NavHost
links the NavController
with a navigation graph that specifies your composables which should be able to navigate between. When you run this NavHost
, it’ll start from startDestination
(profile
). To go to the next screen, you need to call the following:
navController.navigate("friendslist")
It looks simple, but here are two main problems:
- We have
String
as a route that can lead to errors when writing code - We can’t send
parcelable
between screens without boilerplate as shown here.
So, now I’ll show you my own decision about the problem.
Provide Parcelables with Jetpack Compose Navigation
First, we need to create an abstract class that describes our screen. Here’s what that looks like:
abstract class Screen(
open val route: String,
open val arguments: Bundle? = null
) {
constructor(route: String, extra: Extra) : this(route, Bundle().apply { putParcelable(extra.key, extra.parcelable) })
data class Extra(val key: String, val parcelable: Parcelable)
}
After this, we need to create our own implementation of ComposeNavigator
:
@Navigator.Name("composable")
public class ComposeNavigator : Navigator<Destination>()
Where Destination
is our own class, which differs from the original one in that it accepts content lambda with arguments:
@NavDestination.ClassType(Composable::class)
public class Destination(
navigator: ComposeNavigator,
internal val content: @Composable (backStackEntry: NavBackStackEntry, arguments: Bundle?) -> Unit
) : NavDestination(navigator)
The next step will be to create a NavController
that will control our screen:
public class NavExtrasHostController(context: Context, public val startDestination: Screen) :
NavHostController(context) {
private val _currentScreensBackStack: MutableStateFlow<MutableMap<String, Screen>> =
MutableStateFlow(mutableMapOf(startDestination.route to startDestination))
public val currentScreensBackStack: StateFlow<Map<String, Screen>> =
_currentScreensBackStack.asStateFlow()
override fun popBackStack(): Boolean {
_currentScreensBackStack.update { screensMap ->
screensMap.apply { remove(currentDestination?.route) }
}
return super.popBackStack()
}
public fun navigate(screen: Screen, navOptions: NavOptions? = null) {
_currentScreensBackStack.update { it.apply { put(screen.route, screen) } }
navigate(route = screen.route, navOptions = navOptions)
}
}
Here you can see currentScreensBackStack
is a map that stores screens in a route screen, allowing us to move along the graph while keeping the backstack. When the user presses back, we remove the last screen from our back stack.
And you can see, a new func navigate where you can add the next Screen to currentScreensBackStack
.
Now, we will make a func that allows us to create our NavExtrasHostController
, but before we should create a saver class, which saves and restores the NavExtrasHostController
across config change and process death:
private fun NavExtrasControllerSaver(
context: Context,
startDestination: Screen
): Saver<NavExtrasHostController, *> = Saver<NavExtrasHostController, Bundle>(
save = { it.saveState() },
restore = { createNavExtrasController(context, startDestination).apply { restoreState(it) } }
)
And createNavExtrasController()
function:
private fun createNavExtrasController(context: Context, startDestination: Screen) =
NavExtrasHostController(context, startDestination = startDestination).apply {
navigatorProvider.addNavigator(ComposeNavigator())
navigatorProvider.addNavigator(DialogNavigator())
}
After these steps, we can create remember function, which will create out NavExtrasHostController
add our ComposeNavigator
and use NavExtrasControllerSaver
to save and restore it:
@Composable
public fun rememberNavExtrasController(
startDestination: Screen,
vararg navigators: Navigator<out NavDestination>
): NavExtrasHostController {
val context = LocalContext.current
return rememberSaveable(
inputs = navigators,
saver = NavExtrasControllerSaver(context, startDestination)
) {
createNavExtrasController(context, startDestination)
}.apply {
for (navigator in navigators) {
navigatorProvider.addNavigator(navigator)
}
}
}
The next step is creating our own NavHost
:
@Composable
public fun NavHost(
navController: NavExtrasHostController,
graph: NavGraph,
modifier: Modifier = Modifier
) {
val lifecycleOwner = LocalLifecycleOwner.current
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
}
val onBackPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
val onBackPressedDispatcher = onBackPressedDispatcherOwner?.onBackPressedDispatcher
// Setup the navController with proper owners
navController.setLifecycleOwner(lifecycleOwner)
navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
if (onBackPressedDispatcher != null) {
navController.setOnBackPressedDispatcher(onBackPressedDispatcher)
}
// Ensure that the NavController only receives back events while
// the NavHost is in composition
DisposableEffect(navController) {
navController.enableOnBackPressed(true)
onDispose {
navController.enableOnBackPressed(false)
}
}
// Then set the graph
navController.graph = graph
val saveableStateHolder = rememberSaveableStateHolder()
// Find the ComposeNavigator, returning early if it isn't found
// (such as is the case when using TestNavHostController)
val composeNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
ComposeNavigator.NAME
) as? ComposeNavigator ?: return
val visibleEntries by remember(navController.visibleEntries) {
navController.visibleEntries.map {
it.filter { entry ->
entry.destination.navigatorName == ComposeNavigator.NAME
}
}
}.collectAsState(emptyList())
val screensBackStack by navController.currentScreensBackStack.collectAsState()
val backStackEntry = visibleEntries.lastOrNull()
var initialCrossfade by remember { mutableStateOf(true) }
if (backStackEntry != null) {
// while in the scope of the composable, we provide the navBackStackEntry as the
// ViewModelStoreOwner and LifecycleOwner
Crossfade(backStackEntry.id, modifier) {
val lastEntry = visibleEntries.last { entry ->
it == entry.id
}
// We are disposing on a Unit as we only want to dispose when the CrossFade completes
DisposableEffect(Unit) {
if (initialCrossfade) {
// There's no animation for the initial crossfade,
// so we can instantly mark the transition as complete
visibleEntries.forEach { entry ->
composeNavigator.onTransitionComplete(entry)
}
initialCrossfade = false
}
onDispose {
visibleEntries.forEach { entry ->
composeNavigator.onTransitionComplete(entry)
}
}
}
lastEntry.LocalOwnersProvider(saveableStateHolder) {
val previousScreenExtras = screensBackStack[lastEntry.destination.route]?.arguments
val newLastEntryArguments = (lastEntry.arguments ?: Bundle()).apply {
if (previousScreenExtras != null) {
putAll(previousScreenExtras)
}
}
(lastEntry.destination as Destination).content(lastEntry, newLastEntryArguments)
}
}
}
val dialogNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
Companion.NAME
) as? DialogNavigator ?: return
// Show any dialog destinations
DialogHost(dialogNavigator)
}
An overloaded function with route:
@Composable
public fun NavExtrasHost(
navController: NavExtrasHostController,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) {
NavHost(
navController,
remember(route, navController.startDestination.route, builder) {
navController.createGraph(navController.startDestination.route, route, builder)
},
modifier
)
}
After these steps, here is the last one we need to create a composable
function which sends arguments to content:
public fun NavGraphBuilder.composable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
content: @Composable (backStackEntry: NavBackStackEntry, arguments: Bundle?) -> Unit
) {
addDestination(
Destination(provider[ComposeNavigator::class], content).apply {
this.route = route
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
}
)
}
So, we have updated the composable
function and added our arguments to the navigation.
How to Use
Before starting to use our implementation of the navigation library, we should describe our screens. Here’s the code:
data class ProfilePage(val id: String) : Screen(PROFILE_ROUTE, Screen.Extra(PROFILE_EXTRAS_KEY, Profile(id))) {
@Parcelize
data class Profile(val id: String) : Parcelable
companion object {
const val PROFILE_ROUTE = "profile"
const val PROFILE_EXTRAS_KEY = "profile_extras_key"
@Composable
fun Page(profile: Profile) {
Text(profile.id)
}
}
}
const val FRIENDS_LIST_ROUTE: String = "friendsList"
object FriendsList : Screen(FRIENDS_LIST_ROUTE) {
@Composable
fun Page(navigateNextScreen: (String) -> Unit) {
LazyColumn {
items(10) {
Text(it.toString(), modifier = Modifier
.fillParentMaxWidth()
.clickable { navigateNextScreen(it.toString()) })
}
}
}
}
After this, we should implement NavExtrasHost
, and that’s all:
val navController = rememberNavExtrasController(startDestination = FriendsList)
NavExtrasHost(navController = navController) {
composable(ProfilePage.PROFILE_ROUTE) { _, arguments ->
val profile: Profile = arguments?.getParcelable(PROFILE_EXTRAS_KEY) ?: return@composable
ProfilePage.Page(profile)
}
composable(FriendsList.route) { _, _ ->
FriendsList.Page(navigateNextScreen = { navController.navigate(ProfilePage(it)) })
}
}
So, now you can navigate with parcelable
arguments.
That’s all. Thanks for reading.