Cleaning up the Navigation (Jetpack Compose)

Sat, Jun 5, 2021 3-minute read

Prerequisite

  • Basic understanding of Jetpack compose
  • Familiarity with the navigation system of compose

Suggested reads

Jetpack compose has been out for a while now and so is the navigation system, it’s simple and direct.

A simple implementation of navigation would look something like

val navController = rememberNavController()

NavHost(navController = navController, startDestination = "home") {
                composable("home") {
                    HomeScreen(
                        viewModel,
                        navController,
                    )
                }
                composable("about") {
                    AboutScreen(
                        navController
                    )
                }
                composable(
                    "movie/{$movieId}",
                    arguments = listOf(navArgument("movieId") {
                        type = NavType.IntType
                    })
                ) {
                    val id = it.arguments?.getInt("movieId") ?: -1
                    MovieScreen(viewModel = viewModel, movieId = id, navController = navController)
                }
 }

The above code would work fine, but let’s be careful with two things.

  1. Hard-coded strings
  2. Passing instance of navController to every composable

Let’s organize string literals.

object DestinationConstants {
    const val HOME = "home"
    const val MOVIE = "movie"
    const val ABOUT = "about"
}

We can still improve this by leveraging Kotlin’s feature to use. Let’s create a Sealed class now.

sealed class Destination(val route: String) {
    object Home : Destination(DestinationConstants.HOME)
    object About : Destination(DestinationConstants.ABOUT)
    object Movie : Destination("${DestinationConstants.MOVIE}/{movieId}")
}

This looks clean. Now let’s fix the strings for arguments.

sealed class Destination(val route: String) {
    object Home : Destination(DestinationConstants.HOME)

    object About : Destination(DestinationConstants.ABOUT)

    object Movie : Destination("${DestinationConstants.MOVIE}/{${Args.MovieId}}") {
        object Args {
            const val MovieId = "movieID"
        }
    }
}

This is as clean as it gets, now let’s try to make sure that we don’t have to pass an instance of navController everywhere.

Let’s create a class that works as a wrapper over navController

class Actions(navController: NavController) {
    val openMovie: (Int) -> Unit = { movieId ->
        navController.navigate("${DestinationConstants.MOVIE}/$movieId")
    }
    val openAbout: () -> Unit = {
        navController.navigate(DestinationConstants.ABOUT)
    }
    val pop: () -> Unit = {
        navController.popBackStack()
    }
}

This seems good, now let’s see how can we actually use it in our code.

val navController = rememberNavController()
val action = remember(navController) { Actions(navController) }

NavHost(navController = navController, startDestination = Destination.Home.route) {
                composable(Destination.Home.route) {
                    HomeScreen(
                        viewModel,
                        action.openAbout,
                        action.openMovie,
                    )
                }
                composable(Destination.About.route) {
                    AboutScreen(
                        action.pop
                    )
                }
                composable(
                    Destination.Movie.route,
                    arguments = listOf(navArgument(Destination.Movie.Args.MovieId) {
                        type = NavType.IntType
                    })
                ) {
                    val id = it.arguments?.getInt(Destination.Movie.Args.MovieId) ?: -1
                    MovieScreen(viewModel = viewModel, movieId = id, navigateBack = action.pop)
                }
}

And our Navigation-related code can stay in one file.

sealed class Destination(val route: String) {
    object Home : Destination(DestinationConstants.HOME)

    object About : Destination(DestinationConstants.ABOUT)

    object Movie : Destination("${DestinationConstants.MOVIE}/{${Args.MovieId}}") {
        object Args {
            const val MovieId = "movieID"
        }
    }
}

private object DestinationConstants {
    const val HOME = "home"
    const val MOVIE = "movie"
    const val ABOUT = "about"
}

class Actions(navController: NavController) {
    val openMovie: (Int) -> Unit = { movieId ->
        navController.navigate("${DestinationConstants.MOVIE}/$movieId")
    }
    val openAbout: () -> Unit = {
        navController.navigate(DestinationConstants.ABOUT)
    }
    val pop: () -> Unit = {
        navController.popBackStack()
    }
}

This way we can keep our strings private at the same time, we can make sure our constants are all at the same place and make sure we are not exposing the instance of navController directly everywhere.

The above-mentioned guide can help you fix a few things in your code.

  1. Making sure your string literals are private and maintained in a single place.
  2. Abstracting the instance of navController by using a wrapper class.

To see this code in action, you can check out this repository

I hope this article was informative and helped you clean up your navigation code. Feel free to share your feedback or queries.