| |

Simplify your Compose Routes with AndroidX Navigation Kotlin DSL

Simplify your Compose Routes with AndroidX Navigation Kotlin DSL

Before Navigation version 2.8.0 setup we define every destination as a raw string, navArgument() everywhere and then manually parse bundles in your ViewModels.
Over time this leads to:

  • Crashes – route not found: movie‑detail/123
  • Boilerplate overload – string constants, key constants, navArgument lists, bundle parsing
  • Poor refactorability – rename a route and you’ve got to hunt down every usage

With AndroidX Navigation 2.8.0 and above you get a type‑safe Kotlin DSL that turns each screen route into a real Kotlin type, no more string literals, no more unsafe casts and no extra Gradle plugins for Safe‑Args. Below we’ll step through migrating android app to this new approach and explain how it all fits together.

Upgrading your dependencies above or 2.8.0

implementation "androidx.navigation:navigation-compose:2.8.0"

1. Defining your route types

Before :

object AppDestinations {
  // no‑arg screens
  const val HOME_ROUTE = "home"

  // arg‑taking screen
  const val SEARCH_ROUTE = "search"
  const val SEARCH_TYPE = "searchType"

  // arg‑taking screen
  const val MOVIE_DETAILS_ROUTE = "movie-detail"
  const val MOVIE_DETAILS_ID_KEY = "movieId"
}

Every destination is a String. Arguments live in the route template ("search/{searchType}") and you manually define them with navArgument(…).

After : A sealed, serializable hierarchy

@Serializable
sealed interface AppDestinations {

    @Serializable
    data object Home : AppDestinations

    @Serializable
    data class Search(val searchType: SearchType) : AppDestinations

    @Serializable
    data class MovieDetail(val movieId: Int) : AppDestinations
}
  • object for zero‑arg routes
  • data class for routes carrying any parameters
  • Everything annotated @Serializable so Kotlin Serialization can (de)serialize your route into a Bundle

2. Rewriting Your NavHost

Before : string‑based NavHost

NavHost(
    navController = navController,
    startDestination = AppDestinations.HOME_ROUTE,
) {
    composable(route = AppDestinations.HOME_ROUTE) {
        HomeScreen()
    }

    composable(
        route = "${AppDestinations.SEARCH_ROUTE}/{${routes.SEARCH_TYPE}}",
        arguments = listOf(
            navArgument(routes.SEARCH_TYPE) { type = NavType.EnumType(SearchType::class.java) },
        ),
    ) {
        SearchScreen()
    }

    composable(
        route = "${AppDestinations.MOVIE_DETAILS_ROUTE}/{${routes.MOVIE_DETAILS_ID_KEY}}",
        arguments = listOf(
            navArgument(routes.MOVIE_DETAILS_ID_KEY) { type = NavType.IntType },
        ),
    ) {
        MovieDetailScreen()
    }
}

After : type‑safe DSL NavHost

NavHost(
    navController = navController,
    startDestination = startDestination,
) {
    composable<AppDestinations.Home> {
        HomeScreen()
    }

    composable<AppDestinations.Search> {
        SearchScreen()
    }

    composable<AppDestinations.MovieDetail> {
        MovieDetailScreen()
    }
}
  1. No more string templates and objects instead
  2. Automatic arg definition: the DSL infers navArgument from Search(val searchType: SearchType)

3. Refactoring Navigation actions

Before: URL builders

class NavigationActions(
    private val navController: NavHostController,
) {
    val navigateToSelectedMovie: (Int) -> Unit = { movieId: Int ->
        navController.navigate("${AppDestinations.MOVIE_DETAILS_ROUTE}/$movieId")
    }

    val navigateToSearch: (SearchType) -> Unit = { searchType: SearchType ->
        navController.navigate("${AppDestinations.SEARCH_ROUTE}/$searchType")
    }

    val navigateUp: () -> Unit = {
        navController.navigateUp()
    }
}

After: sealed helper lambda

class NavigationActions(
    private val navController: NavHostController,
) {
    val navigateToSelectedMovie: (Int) -> Unit = { movieId: Int ->
        navController.navigate(AppDestinations.ActorDetail(movieId))
    }

    val navigateToSearch: (SearchType) -> Unit = { searchType: SearchType ->
        navController.navigate(AppDestinations.Search(searchType))
    }

    val navigateUp: () -> Unit = {
        navController.navigateUp()
    }
}

All your navigation calls are simple one‑liner passing a Kotlin object.

4. ViewModel Integration – receive args from SavedStateHandle

Before: manual bundle parsing

@HiltViewModel
class SearchViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val searchRepository: SearchRepository,
) : ViewModel() {
    val searchType: SearchType = checkNotNull(savedStateHandle[SEARCH_TYPE])
}

After : reconstruct route object

@HiltViewModel
class SearchViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val searchRepository: SearchRepository,
) : ViewModel() {
    val searchType: SearchType = savedStateHandle.toRoute<AppDestinations.Search>().searchType
}
  • The DSL writes your args into the SavedStateHandle bundle
  • toRoute<T>() applies Kotlin Serialization to recreate your route type

5. Migration – Incremental & Hybrid Strategies

Mixed XML + Compose :
If you have an XML‑defined NavHostFragment, add the Compose DSL via the new fragment adapter.

<fragment
  android:id="@+id/composeNavHost"
  android:name="androidx.navigation.compose.ComposableNavHostFragment"
  app:navGraph="@navigation/main_graph" />
navController.addGraph(/* Compose‐DSL built graph */)

This lets you migrate one feature at a time. Pick routes for migration and migrate only those routes to the DSL. Verify end‑to‑end with UI tests and gradually continue for all destinations.

6. How the Kotlin DSL Simplifies Navigation Under the Hood

While it looks like you’re just swapping strings for objects, the DSL hides a lot behind a clean, type‑safe API. Here’s what’s happening internally in our Navigation example

inline fun <reified T : Any> NavGraphBuilder.composable(
  typeMap: Map<KType, NavType<*>> = emptyMap(),
  noinline content: @Composable (NavBackStackEntry) -> Unit
) { }
  • We get a new composable<T>() call simply by importing the navigation compose package.
  • Internally this calls into ComposeNavigatorDestinationBuilder(provider[…], T::class, typeMap, content), which registers a destination whose route pattern and arguments are derived from T’s @Serializable metadata.

Conclusion :

Migrating to Navigation 2.8.0’s type‑safe Kotlin DSL:

  • Eliminates string‑based routes
  • Provides compile‑time safety and better refactoring
  • Automatically handles Safe‑Args via Kotlin Serialization
  • Keeps all destinations centralized in one sealed hierarchy
  • Simplifies ViewModel arg retrievals

Resources :

Documentation – Type safety in Kotlin DSL and Navigation Compose
Jetpack Compose Navigation example – list to detail
Pull request with migration changes

Author

Similar Posts

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.