When using the Kotlin DSL to construct your graph, keeping destinations and navigation events in a single file can be difficult to maintain. This is especially true if you have multiple independent features.
Extract destinations
You should move your destinations into NavGraphBuilder
extension
functions. They should live close to the routes which define them, and the
screens that they display. For example, consider the following app-level code
that creates a destination which shows a list of contacts:
// MyApp.kt
@Serializable
object Contacts
@Composable
fun MyApp() {
...
NavHost(navController, startDestination = Contacts) {
composable<Contacts> { ContactsScreen( /* ... */ ) }
}
}
You should move the navigation-specific code into a separate file:
// ContactsNavigation.kt
@Serializable
object Contacts
fun NavGraphBuilder.contactsDestination() {
composable<Contacts> { ContactsScreen( /* ... */ ) }
}
// MyApp.kt
@Composable
fun MyApp() {
...
NavHost(navController, startDestination = Contacts) {
contactsDestination()
}
}
The routes and destination definitions are now separate from the main app and
you can update them independently. The main app is only dependent on a single
extension function. In this case, that is
NavGraphBuilder.contactsDestination()
.
The NavGraphBuilder
extension function forms the bridge between a stateless
screen-level composable function and Navigation-specific logic. This layer can
also define where the state comes from and how you handle events.
Example
The following snippet introduces a new destination to display a contact's details, and updates the existing contact list destination to expose a navigation event to display the contact's details.
Here's a typical set of screens that can be internal
to their own module, so
that other modules cannot access them:
// ContactScreens.kt
// Displays a list of contacts
@Composable
internal fun ContactsScreen(
uiState: ContactsUiState,
onNavigateToContactDetails: (contactId: String) -> Unit
) { ... }
// Displays the details for an individual contact
@Composable
internal fun ContactDetailsScreen(contact: ContactDetails) { ... }
Create destinations
The following NavGraphBuilder
extension function creates a destination
which shows the ContactsScreen
composable. In addition, it now connects
the screen with a ViewModel
that provides the screen UI state and handles the
screen-related business logic.
Navigation events, such as navigating to the contact details destination, are
exposed to the caller rather than being handled by the ViewModel
.
// ContactsNavigation.kt
@Serializable
object Contacts
// Adds contacts destination to `this` NavGraphBuilder
fun NavGraphBuilder.contactsDestination(
// Navigation events are exposed to the caller to be handled at a higher level
onNavigateToContactDetails: (contactId: String) -> Unit
) {
composable<Contacts> {
// The ViewModel as a screen level state holder produces the screen
// UI state and handles business logic for the ConversationScreen
val viewModel: ContactsViewModel = hiltViewModel()
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
ContactsScreen(
uiState,
onNavigateToContactDetails
)
}
}
You can use the same approach to create a destination which displays the
ContactDetailsScreen
. In this case, instead of obtaining the UI state from a
view model, you can obtain it directly from the NavBackStackEntry
.
// ContactsNavigation.kt
@Serializable
internal data class ContactDetails(val id: String)
fun NavGraphBuilder.contactDetailsScreen() {
composable<ContactDetails> { navBackStackEntry ->
ContactDetailsScreen(contact = navBackStackEntry.toRoute())
}
}
Encapsulate navigation events
In the same way that you encapsulate destinations, you can encapsulate
navigation events to avoid exposing route types unnecessarily. Do this by
creating extension functions on NavController
.
// ContactsNavigation.kt
fun NavController.navigateToContactDetails(id: String) {
navigate(route = ContactDetails(id = id))
}
Bring it together
The navigation code for displaying contacts is now cleanly separated from the app's navigation graph. The app needs to:
- Call
NavGraphBuilder
extension functions to create destinations - Connect those destinations by calling
NavController
extension functions for navigation events
// MyApp.kt
@Composable
fun MyApp() {
...
NavHost(navController, startDestination = Contacts) {
contactsDestination(onNavigateToContactDetails = { contactId ->
navController.navigateToContactDetails(id = contactId)
})
contactDetailsDestination()
}
}
In summary
- Encapsulate your navigation code for a related set of screens by placing it in a separate file
- Expose destinations by creating extension functions on
NavGraphBuilder
- Expose navigation events by creating extension functions on
NavController
- Use
internal
to keep screens and route types private