Simple and clean navigation for JetPack Compose now with type safe parameters

This article is a revised version of my previous article on Simple Navigation. Given the recent release of Compose Navigation with type-safe parameter support, I’ve updated the accompanying sample project to showcase these new features.

With type-safe navigation, you can now pass class instances as parameters during navigation, eliminating the need for string-based parameters like “EditScreen/12”. This ensures that you receive the correct data when navigating to your destination screen.

New source code for this article is available on GitHub

So, what changes you need to incorporate to support new type safe navigation? For the beginning let us check NavRoutes file

In original article this class looks like this:

object NavRoutes {
    const val MAIN_SCREEN = "MainScreen"
    const val SCREEN1 = "Screen1"
    const val SCREEN2 = "Screen2"
    const val SCREEN3 = "Screen3"
    const val SCREEN_WITH_PARAMETER = "ScreenWithParameter"
    const val SCREEN_WITH_PARAMETER_INSIDE = "ScreenWithParameter/{${NavigationParams.PARAMETER}}"

}

object NavigationParams {
    const val PARAMETER = "Parameter"
}

As you can see here all routes were created as a string constants, in updated version same file will look like this:

object NavRoutes {
    @Serializable
    object MainScreen

    @Serializable
    object Screen1

    @Serializable
    object Screen2

    @Serializable
    object Screen3

    @Serializable
    data class ScreenWithParameter(val parameter: String)
}

Much cleaner looking code, isn’t it? Any object or data class used for navigation should be marked as @Serialized

Of course, to support new type of navigation CustomNavigator class must be updated too.

enum class NavigationCommand {
    /**
     * Navigate to destination
     */
    NAVIGATE,

    /**
     * Go back
     */
    GO_BACK,

    /**
     * Navigate back to specific destination
     */
    GO_BACK_TO,

    /**
     * Navigate back to specific destination, inclusive=true
     */
    GO_BACK_TO_INCLUSIVE,

    /**
     * Navigate to destination and remove current view from nav stack
     */
    NAVIGATE_AND_CLEAR_CURRENT,

    /**
     * Navigate to destination and remove all previous destinations making current one as a top
     */
    NAVIGATE_AND_CLEAR_TOP
}

data class NavigationAction(
    val navigationCommand: NavigationCommand,
    val parcelableArguments: Map<String, Parcelable> = emptyMap(),
    val navOptions: NavOptions = NavOptions.Builder().build(),        // No NavOptions as default,
    val typeSafeDestination: Any? = null
)

@Suppress("unused")
@Singleton
class CustomNavigator @Inject constructor() {
    private val _navActions =
        MutableSharedFlow<NavigationAction>(replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST)

    val navActions: SharedFlow<NavigationAction> = _navActions

    fun navigate(navAction: NavigationAction) {
        _navActions.tryEmit(navAction)
    }

    fun navigate(data: Any) {
        navigate(NavigationAction(NavigationCommand.NAVIGATE, typeSafeDestination = data))
    }

    fun navigateAndClear(destination: Any) {
        navigate(NavigationAction(NavigationCommand.NAVIGATE_AND_CLEAR_TOP, typeSafeDestination = destination))
    }

    fun goBack() {
        navigate(NavigationAction(NavigationCommand.GO_BACK))
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    fun runNavigationCommand(action: NavigationAction, navController: NavHostController) {
        when (action.navigationCommand) {
            NavigationCommand.GO_BACK -> navController.navigateUp()
            NavigationCommand.GO_BACK_TO -> {
                navController.goBackTo(action.typeSafeDestination!!, inclusive = false)
            }

            NavigationCommand.NAVIGATE -> {
                navController.navigate(action.typeSafeDestination!!) {
                    launchSingleTop = true
                }
            }

            NavigationCommand.NAVIGATE_AND_CLEAR_CURRENT -> {
                navController.navigate(action.typeSafeDestination!!) {
                    navController.currentBackStackEntry?.destination?.route?.let {
                        popUpTo(it) { inclusive = true }
                    }
                }
            }

            NavigationCommand.NAVIGATE_AND_CLEAR_TOP -> {
                navController.navigateAndReplaceStartRoute(action.typeSafeDestination!!)
            }

            NavigationCommand.GO_BACK_TO_INCLUSIVE -> {
                navController.goBackTo(action.typeSafeDestination!!, inclusive = true)
            }
        }

        _navActions.resetReplayCache()
    }

    fun goBackTo(destination: Any) {
        navigate(NavigationAction(NavigationCommand.GO_BACK_TO, typeSafeDestination = destination))
    }

    fun goBackTo(destination: Any, inclusive: Boolean) {
        if (inclusive) {
            navigate(NavigationAction(NavigationCommand.GO_BACK_TO_INCLUSIVE, typeSafeDestination = destination))
        } else {
            navigate(NavigationAction(NavigationCommand.GO_BACK_TO, typeSafeDestination = destination))
        }
    }

    fun navigateAndClearCurrentScreen(destination: Any) {
        navigate(NavigationAction(NavigationCommand.NAVIGATE_AND_CLEAR_CURRENT, typeSafeDestination = destination))
    }
}

fun NavHostController.navigateAndReplaceStartRoute(newHomeRoute: Any) {
    popBackStack(graph.startDestinationId, true)
    graph.setStartDestination(newHomeRoute)
    navigate(newHomeRoute)
}

fun NavHostController.goBackTo(routeName: Any, inclusive: Boolean = false) {
    popBackStack(routeName, inclusive)
}

This update introduces a typeSafeDestination property of type Any? to the NavigationAction class, replacing the previous destination string property. Furthermore, all functions that previously accepted string parameters have been modified to accept Any type parameters. While the parcelableArguments field is retained, it is not currently used in this specific example.

Additional changes were introduced in MainActivity’s Navigation function. This is how it looks now

@Composable
fun Navigation(navController: NavHostController) {
    NavHost(
        navController = navController, startDestination = NavRoutes.MainScreen
    ) {
        composable<NavRoutes.MainScreen> {
            MainScreen()
        }
        composable<NavRoutes.Screen1> {
            Screen1()
        }
        composable<NavRoutes.Screen2> {
            Screen2()
        }
        composable<NavRoutes.Screen3> {
            Screen3()
        }
        composable<NavRoutes.ScreenWithParameter> { backStack ->
            val data: NavRoutes.ScreenWithParameter = backStack.toRoute()
            ScreenWithParameter(data.parameter)
        }
    }
}

When navigating to the destination without parameters, you only need to change declaration for composable. In previous example you pass destination as a string like this

composable(NavRoutes.MAIN_SCREEN) {
            MainScreen()
        }

Now you pass destination object like this:

composable<NavRoutes.MainScreen> {
            MainScreen()
        }

Not a big change, but for the destination with parameters you have to get the parameter out of back stack and pass it into composable function. This is how it is done in my example:

composable<NavRoutes.ScreenWithParameter> { backStack ->
            val data: NavRoutes.ScreenWithParameter = backStack.toRoute()
            ScreenWithParameter(data.parameter)
        }

As you can see this is better than the previous version where you need to describe parameter and then manually extract it and save into savedStateHandle.

But this is not the end of changes, unfortunately. Since I have used Hilt for ViewModels now I have to do some additional changes. First, let’s check how we retrieved parameter in previous version.


@HiltViewModel
class ScreenWithParameterViewModel @Inject constructor(
    state: SavedStateHandle,
    private val customNavigator: CustomNavigator
) : ViewModel() {

    var passedParameter by mutableStateOf("")
        private set

    init {
        passedParameter = state[NavigationParams.PARAMETER] ?: ""
    }

    fun goBack(){
        customNavigator.goBack()
    }
}

In the previous version, parameters were retrieved from the SavedStateHandle object. While this allowed for obtaining objects of the desired type, there was a risk of unexpected parameter types. The new approach eliminates this issue by passing parameters directly to composable functions.

This is updated ViewModel

@HiltViewModel(assistedFactory = ScreenWithParameterViewModel.ScreenWithParameterViewModelFactory::class)
class ScreenWithParameterViewModel @AssistedInject constructor(
    @Assisted val passedParameter: String,
    private val customNavigator: CustomNavigator
) : ViewModel() {

    fun goBack() {
        customNavigator.goBack()
    }

    @AssistedFactory
    interface ScreenWithParameterViewModelFactory {
        fun create(passedParameter: String): ScreenWithParameterViewModel
    }
}

A significant change is the use of @AssistedInject for the constructor and @Assisted for the passedParameter. This technique, known as assisted injection in Hilt, is particularly useful for passing parameters to ViewModel constructors.

To implement this, you must create a special interface marked as @AssistedFactory within your class. This interface should contain a single create function that instantiates the ViewModel with the required parameters.

Additional changes also required in composable function. Previous version:

@Composable
fun ScreenWithParameter(viewModel: ScreenWithParameterViewModel = hiltViewModel()) {
    Column(
        modifier = Modifier.fillMaxSize()
    ) {

New version

@Composable
fun ScreenWithParameter(data: String) {
    val viewModel =
        hiltViewModel<ScreenWithParameterViewModel, ScreenWithParameterViewModel.ScreenWithParameterViewModelFactory>() { factory ->
            factory.create(data)
        }

The primary difference lies in ViewModel instantiation. In the new version, we provide the ViewModel’s type, its assisted factory, and the actual code for creating the ViewModel to the hiltViewModel function. Hilt then executes the provided code and returns the instantiated ViewModel.

The lifecycle of this ViewModel is linked to the navigation route. If you leave the screen, the ViewModel will be disposed. However, if you navigate away and then return, the same ViewModel instance will persist.

Using new Type-Safe navigation leverages Kotlin Serialization for some functionalities. You can read more about Kotlin Serialization here https://kotlinlang.org/docs/serialization.html#serialize-and-deserialize-json

Spread the love
Tags:

Related Post