Skip to content

Get started

This library sits on top of AndroidX Navigation and solves 2 problems that we ran into after starting to adopt it.

  1. We believe that navigation should be triggered from the business logic. This way it is easily testable in isolation without having to actually navigate inside a running app. However you usually want to keep Android specific components out of these layers and passing NavController there would risk leaking the Activity.

  2. The XML based declaration of destinations did not scale well with our heavily modularized code base. Each screen is usually located in its own feature Gradle module and those should stay independent of each other. That effectively meant that each XML file had a single destination and all destination ids needed to be defined somewhere else so that feature modules can navigate to each other. Since the code generated by safe-args plugin also lives in the feature module it’s not usable by other features either. That meant that in the end we didn’t get the nice type safety advantages of safe-args and the XML was just boilerplate that didn’t form a real graph in the end.

Dependency

Similar to AndroidX navigation, the library provides 2 different runtime implementations. One for Fragment based apps and one for pure Compose apps. If an app uses Compose but the composables are hosted inside fragments it falls into the Fragment category.

implementation("com.freeletics.khonshu:navigation-compose:0.24.0")
implementation("com.freeletics.khonshu:navigation-fragment:0.24.0")

Destinations

To replace the XML and get type safety for navigation the library has its own concept of destinations. A destination consists of 2 parts: - the declaration of the destination itself which determines what is shown when the destination is navigated to; - the route - a way to reach the destination.

The route part is represented by the NavRoute interface. Each destination will get its own implementation of this interface and screens can use it to navigate to the destination.

The most minimal implementation of NavRoute would be for a screen that doesn’t require any arguments can be a simple Kotlin object:

@Parcelize
data object HomeScreenRoute : NavRoute

The more common case when a destination needs arguments passed to it would look like this:

@Parcelize
data class DetailScreenRoute(
    val id: String,
) : NavRoute

Internally the library will pass the route to the screen itself so that it can access the parameters.

The other part of the destination is represented by NavDestination.

If we take the DetailScreenRoute example from above, declaring the destination for it would look like this:

val detailScreenDestination: NavDestination = ScreenDestination<DetailScreenRoute> { route: DetailScreenRoute ->
    DetailScreen(route)
}

The ScreenDestination function will return a new NavDestination which is linked to the route that was passed as the generic type parameter. The lambda function then gets an instance of that NavRoute and calls the @Composable function that should be shown.

There is also an OverlayDestination function to declare destinations that use a dialog or bottom sheet as a container instead of being shown full screen.

val infoSheetDestination: NavDestination = OverlayDestination { route: InfoSheetRoute ->
    ModalBottomSheet(onDismissRequest = { /* TODO */ }) {
        InfoSheetContent(route)
    }
}

val confirmationDialogDestination: NavDestination = OverlayDestination { route: ConfirmationDialogRoute ->
    Dialog(onDismissRequest = { /* TODO */ }) {
        ConfirmationDialogContent(route)
    }
}
val detailScreenDestination: NavDestination = ScreenDestination<DetailScreenRoute, DetailFragment>()

The ScreenDestination function will return a new NavDestination which is linked to the route that was passed as the first generic type parameter. The second type parameter is the Fragment that will be shown for this destination.

There is also a DialogDestination function to declare destinations that use a dialog or bottom sheet as a container instead of being shown full screen. Fragments used with this function should extend DialogFragment.

val infoSheetDestination: NavDestination = DialogDestination<InfoSheetRoute, InfoBottomSheetFragment>()

val confirmationDialogDestination: NavDestination = DialogDestination<ConfirmationDialogRoute, ConfirmationDialogFragment>()

Inside a Fragment the requireRoute extension method can be used to obtain the NavRoute used to navigate to it.

For example the DetailFragment could do this to obtain DetailScreenRoute and access the id in it:

val route = requireRoute<DetailScreenRoute>()

See navigating to Activities and other apps for defining a destination that leads to an Activity.

Setup

A Set of all created NavDestination instances is then used to set up the navigation host that takes care of displaying the currently visible destination(s).

setContent {
    NavHost(
        // route to the screen that should be shown initially
        startRoute = HomeScreenRoute,
        // should contain all destinations that can be navigated to
        destinations = setOf(
            homeDestination,
            detailScreenDestination,
        ),
    )
}

These destinations can then be passed to a NavHostFragment by putting them into a set:

navHostFragment.setGraph(
    // route to the screen that should be shown initially
    startRoute = HomeScreenRoute,
    // should contain all destinations that can be navigated to
    destinations = setOf(
        homeDestination,
        detailScreenDestination,
    ),
)

Scalability

For the simplicity of the examples above the destinations were just kept in a variable and then used to manually create a set of all of them. In a modularized project the routes are usually declared in a shared module and the destinations then in the individual feature modules where the respective screen is implemented. The set would then mean that for each new feature module a developer would need to remember to go to the app module and add the destination to the set.

In practice it makes more sense to use dagger multi bindings to declare and collect destinations:

@Module
object DetailScreenModule {
    @Provides
    @IntoSet
    fun provideDetailScreenDestinations() = ScreenDestination<DetailScreenRoute> {
        DetailScreen(it)
    }
}
@Module
object DetailScreenModule {
    @Provides
    @IntoSet
    fun provideDetailScreenDestinations() = ScreenDestination<DetailScreenRoute, DetailFragment>()
}

Then an Activity or something else can simply inject a Set<NavDestination> and use that for the set up:

class MainActivity : ComponentActivity() {
    @Inject
    lateinit var destinations: Set<NavDestination>

    override fun onCreate(savedInstanceState: Bundle) {
        super.onCreate()
        // inject the activity
        setContent{
            NavHost(
                startRoute = HomeScreenRoute,
                destinations = destinations,
            )
        }
    }
}
class MainActivity : FragmentActivity() {
    @Inject
    lateinit var destinations: Set<NavDestination>

    override fun onCreate(savedInstanceState: Bundle) {
        super.onCreate()
        // inject the activity
        // obtain NavHostFragment
        navHostFragment.setGraph(
            startRoute = HomeScreenRoute,
            destinations = destinations,
        }
    }
}

The third main class that the library provides is NavEventNavigator. This class solves the first of our 2 problems, triggering navigation from outside the UI layer. For that it provides all the primitive navigation operations:

// navigate to the destination that the given route leads to
navigator.navigateTo(DetailScreenRoute("some-id"))
// navigate up in the hierarchy
navigator.navigateUp()
// navigate to the previous destination in the backstack
navigator.navigateBack()
// navigate back to the destination belonging to the referenced route and remove all destinations
// in between from the back stack, depending on inclusive the destination
navigator.navigateBackTo<MainScreenRoute>(inclusive = false)

These methods can be called from anywhere and it’s safe to hold an instance of the NavEventNavigator in places where it survives configuration changes.

For the navigation to actually be executed call handleNavigation(this, navigator) from your one of the Fragment lifecycle methods or NavigationSetup(navigator) from a composable function.

It’s possible to simply instantiate an instance of NavEventNavigator but in the Freeletics code base we usually create a subclass in each feature module that has some higher level methods. For example it could have a method that encapsulates the creation of the route:

fun navigateToDetail(id: String) {
    navigateTo(DetailScreenRoute(id))
}
They can also contain more complex logic like navigating to one route or another based on a parameter or first calling navigateBackTo and then navigateTo with a new route. This keeps the navigation logic more separated and it can be easily called from more than one place.

Handling back clicks

NavEventNavigator has a backPresses() method that returns Flow<Unit> which will emit whenever Android’s back button is used. While this Flow is collected the default back handling is disabled. This can be used to for example show a confirmation dialog before navigating back.

Other functionality

There are various additional NavEventNavigator APIs to simplify common navigation related tasks: