Better Programming

Advice for programmers.

Follow publication

Navigating With Parcelable Arguments in Jetpack Compose

Use the Navigation compose in your Android apps

Usman Akhmedov
Better Programming
Published in
5 min readNov 30, 2022

image by author

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:

  1. We have String as a route that can lead to errors when writing code
  2. 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.

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Responses (1)

is it support nested navigation by using navigation(....) ??
example
navigation(
route = Graph.HOME,
startDestination = DetailsScreen.Home.route
) {...