When we build navigation we split our app into screens. Each screen has its own composable function, UI state, view model and navigation extensions.
Screen composable as well as all its children should not have direct access to the ViewModel or NavController. Instead, they should accept states and event lambdas as parameters.
@Composable
fun ProfileScreen(
// Screen accepts only data and lambdas
state: ProfileUiState,
onRefresh: () -> Unit,
onNavigateBack: () -> Unit,
onNavigateToChangePassword: () -> Unit,
onNavigateToDeleteAccount: () -> Unit,
) {
// ...
}
For each screen you should create a separate file with navigation extensions.
First extension extends NavController
and allows us to navigate to the screen.
Second extension extends NavGraphBuilder
and allows us to add the screen to the navigation graph.
// ProfileNavigation.kt
// Keep arguments and route as constants
private const val PROFILE_ID = "profileId"
const val PROFILE_ROUTE = "profile/{$PROFILE_ID}"
// Extension which allows us to navigate to the screen
fun NavController.navigateToProfile(profileId: String) {
PROFILE_ROUTE
.replace("{$PROFILE_ID}", profileId)
.let { navigate(it) }
}
// Extension which allows us to add the screen to the navigation graph
fun NavGraphBuilder.profileScreen(
onNavigateBack: () -> Unit,
onNavigateToChangePassword: () -> Unit,
onNavigateToDeleteAccount: () -> Unit,
) {
composable(route = PROFILE_ROUTE) {
val viewModel: ProfileViewModel = hiltViewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
ProfileScreen(
state = state,
// Events invoking business operations are delegated to the ViewModel
onRefresh = { viewModel.refresh() },
// Events invoking navigation are delegated to the parent composable
onNavigateBack = onNavigateBack,
onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
)
}
}
In the NavHost
composable we define a navigation graph using the navigation extensions which we created for each screen.
@Composable
fun MainNavHost() {
val navController = rememberNavController()
NavHost(
navController = navController,
// Set start destination using constants
startDestination = HOME_ROUTE,
) {
// Add screens to the navigation graph using navigation extensions
homeScreen(
// Handle navigation events by calling the navigation extensions
onNavigateToProfile = { profileId -> navController.navigateToProfile(profileId) },
)
profileScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToChangePassword = { navController.navigateToChangePassword() },
onNavigateToDeleteAccount = { navController.navigateToDeleteAccount() },
)
changePasswordScreen(
onNavigateBack = { navController.popBackStack() },
)
deleteAccountScreen(
onNavigateBack = { navController.popBackStack() },
)
}
}
In previous example each NavGraphBuilder
extension accepts lambdas which represent navigation events.
This way the navigation graph becomes a Single Source of Truth about the navigation flow.
An alternative solution is to pass the NavController
to the navigation extensions and use it to navigate between screens.
This approach is not recommended because:
- It couples one screen with other screens as it has to use their navigation extensions.
- It makes the navigation graph harder to understand as it's not so clear how user moves between screens.
@Composable
fun MainNavHost() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = HOME_ROUTE,
) {
// Now it is not so clear how user moves between screens
homeScreen(navController)
profileScreen(navController)
changePasswordScreen(navController)
deleteAccountScreen(navController)
}
}
In previous example we used lambdas to represent navigation events. Each screen exposed its own events without being dependent on anything else.
An alternative solution is to create a single sealed class which represents all navigation events in the app. This approach is not recommended because:
- This kind of sealed class will grow indefinitely as the app grows, and it will become hard to maintain.
- When you split your app into modules, all your modules which contain screens have to depend on the same module which contains this sealed class.
// This class will grow indefinitely, it will become hard to maintain
// and problematic in multi-module projects
sealed class NavigationEvent {
data object NavigateBack : NavigationEvent()
data class NavigateToProfile(val profileId: String) : NavigationEvent()
data object NavigateToChangePassword : NavigationEvent()
data object NavigateToDeleteAccount : NavigationEvent()
}
When we build navigation we split our app into features. Each feature represents some separate business flow with its own screens.
For each feature you should create a separate file with a nested graph.
Nested graphs allow us to split the NavHost
into smaller logical flows.
We define the nested graph by creating navigation extensions, similar to the ones we created for screens.
// OrderGraph.kt
// Same as screen, each nested graph has its own route
const val ORDER_GRAPH_ROUTE = "order-graph"
// We navigate to the nested graph same as to the screen, using the route
fun NavController.navigateToOrderGraph() {
navigate(ORDER_GRAPH_ROUTE)
}
fun NavGraphBuilder.orderGraph(
// Unlike the screen, nested graph accepts the NavController as parameter.
// We use it to perform internal navigation between screens in this flow.
navController: NavController,
// If we want to navigate to the screen which is outside of this flow,
// we expose navigation events, same as for the screen
onExitOrder: () -> Unit,
onNavigateToProduct: (String) -> Unit
) {
navigation(
route = ORDER_GRAPH_ROUTE,
startDestination = DELIVERY_ROUTE
) {
// We add screens the same as to the NavHost
deliveryScreen(
// We use the NavController to navigate between screens in this flow
onNavigateBack = { navController.popBackStack() },
onNavigateToAddress = { navController.navigateToAddress() },
)
addressScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToPayment = { navController.navigateToPayment() },
// We use navigation events to navigate outside of this flow
onExitOrder = onExitOrder,
)
paymentScreen(...)
summaryScreen(...)
confirmationScreen(...)
}
}
In larger projects it is a good approach to split the app into modules.
Each features with its own screens and nested graphs can be a separate module.
Screens, UI states, view models and navigation extensions are kept internal
.
Nested graph is the only public
API of the feature module.
When our navigation is properly split into nested graphs the NavHost
composable becomes very simple.
It only aggregates the nested graphs using their navigation extensions.
It also handles navigation events exposed by these graphs and moves user to different graphs.
@Composable
fun MainNavHost() {
val navCollection = rememberNavController()
NavHost(
navController = navController,
// One of the nested graphs is set as the start destination
startDestination = HOME_GRAP_ROUTE,
) {
homeGraph(
// We pass the NavController to the nested graph,
// so it can perform internal navigation
navController = navController,
// We handle navigation events exposed by the nested graph
// and move user to different graphs
oNavigateToProduct = { productId -> navController.navigateToProduct(productId) },
)
cartGraph(...)
orderGraph(...)
productGraph(...)
}
}
Some screens require additional data to be passed when we navigate to them.
When we pass navigation arguments between screens they are saved to the SavedStateHandle
object.
This object keeps all the data as Any
type, so we have to cast it to the proper type when we read it.
It is a good practice to create a separate wrapper for the navigation arguments which will handle the casting.
We put it in the same file as the navigation extensions.
data class ProfileArgs(val profileId: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
profileId = checkNotNull(savedStateHandle[PROFILE_ID])
)
}
class ProfileViewModel(
savedStateHandle: SavedStateHandle
) {
private val args = ProfileArgs(savedStateHandle)
}
Unlike the navigation for Fragments and Activities, navigation for Compose doesn't support passing Serializable
and Parcelable
objects as navigation arguments.
It is an intentional decision as passing whole objects between screens is considered as a bad practice due several reasons:
- Max size of the navigation arguments is limited and can cause runtime exceptions.
- Passing whole objects is not reactive as we can't observe their changes.
- The more data we pass between screens, the more tightly coupled they become.
// Don't pass whole objects as navigation arguments
// It is enough to pass only the ID of the object
// Let the screen load all the data it needs from the repository
fun NavController.navigateToProduct(product: Product) {
// ...
}
Compose navigation allows us to navigate not only between screens, but also to dialogs and bottom sheets.
Same as for screens, we create navigation extensions for dialogs. We can use both standard dialogs and full screen dialogs.
fun NavGraphBuilder.changePasswordDialog() {
dialog(
route = CHANGE_PASSWORD_ROUTE,
// This way we make the dialog full screen
dialogProperties = DialogProperties(usePlatformDefaultWidth = false)
) {
// ...
}
}
Same as for screens, we create navigation extensions for bottom sheets. Support for bottom sheets is still experimental and requires additional dependency.
fun NavGraphBuilder.deleteAccountBottomSheet() {
bottomSheet(route = DELETE_ACCOUNT_ROUTE) {
// ...
}
}
Deeplinks allow us to navigate to a specific screen in the app using a URL.
As our navigation is already defined using routes, we can easily use the same routes as deeplinks.
// Define the base deeplink for the app
const val BASE_DEEPLINK = "app://com.droidsonroids.playground/"
fun NavGraphBuilder.forgotPasswordScreen() {
composable(
route = FORGOT_PASSWORD_ROUTE,
// Just add deeplinks to the screen and Compose navigation will handle the rest
deepLinks = listOf(
navDeeplink { uriPattern = BASE_DEEPLINK + FORGOT_PASSWORD_ROUTE }
)
) {
// ...
}
}