Get started¶
This library sits on top of AndroidX Navigation and solves 2 problems that we ran into after starting to adopt it.
-
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. -
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.
NavRoute¶
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.
NavDestination¶
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,
}
}
}
NavEventNavigator¶
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))
}
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:
- Activity result handling like
startActivityForResult
/onActivityResult
andActivityResultContract
- Permission result handling like
requestPermissions
/onRequestPermissionsResult
andActivityResultContract.RequestPermissions
- Destination result handling to deliver and obtain results to a previous destination
- Multiple back stack support for supporting something like bottom navigation where each tab has its own separate back stack
- Deep links for sending deep links within the app and for handling deep links coming from the outside.
- Test helpers to make testing navigation logic easier