Navigation is a fundamental aspect of any Android application, especially in modern apps built with Jetpack Compose. In this blog, we will dive deep into the implementation of a navigation architecture that is modular, scalable, and easy to extend. The code we'll explore covers features such as top-level destinations, modular navigation for individual features, and deep links for specific resources.
This step-by-step guide will help you understand the architecture and how you can build a similar system for your application.
A big thanks to NowInAndroid Repository for providing the code example used in this blog, which was taken from their open-source project.
The provided implementation is structured to:
Define Top-Level Destinations: Each top-level destination represents a primary section of the app.
Utilize Modular Navigation: Each feature module handles its own navigation, making the app easier to maintain and scale.
Handle Deep Links: Deep links allow users to navigate to specific screens directly from notifications or external links.
The TopLevelDestination
enum defines the primary sections of the app:
enum class TopLevelDestination(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
val route: KClass<*>,
val baseRoute: KClass<*> = route,
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name,
route = ForYouRoute::class,
baseRoute = ForYouBaseRoute::class,
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title,
route = BookmarksRoute::class,
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests,
route = InterestsRoute::class,
),
}
Each destination has icons for selected and unselected states.
Text resources are associated for accessibility and titles.
Each destination specifies its route and optional base route.
This structure ensures that top-level navigation is clearly defined and easily extendable.
The NiaNavHost
function is the heart of the navigation system. It uses Jetpack Compose's NavHost
to define the navigation graph:
@Composable
fun NiaNavHost(
appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier,
) {
val navController = appState.navController
NavHost(
navController = navController,
startDestination = ForYouBaseRoute,
modifier = modifier,
) {
forYouSection(
onTopicClick = navController::navigateToTopic,
) {
topicScreen(
showBackButton = true,
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
}
bookmarksScreen(
onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToInterests,
)
interestsListDetailScreen()
}
}
NavHost
: Centralizes navigation for the app.
Each section—forYouSection
, bookmarksScreen
, searchScreen
—handles its own navigation logic.
Encapsulation: Each feature is isolated, making it easier to manage individual navigation logic.
Each feature module defines its own navigation. For example, the Bookmarks
feature:
@Serializable object BookmarksRoute
fun NavController.navigateToBookmarks(navOptions: NavOptions) =
navigate(route = BookmarksRoute, navOptions)
fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
composable<BookmarksRoute> {
BookmarksRoute(onTopicClick, onShowSnackbar)
}
}
BookmarksRoute
: Defines the route for the bookmarks screen.
NavGraphBuilder.bookmarksScreen
: Encapsulates the composable and its associated logic.
The feature's navigation is self-contained, ensuring modularity.
Similarly, the ForYou
feature:
fun NavGraphBuilder.forYouSection(
onTopicClick: (String) -> Unit,
topicDestination: NavGraphBuilder.() -> Unit,
) {
navigation<ForYouBaseRoute>(startDestination = ForYouRoute) {
composable<ForYouRoute>(
deepLinks = listOf(
navDeepLink {
uriPattern = DEEP_LINK_URI_PATTERN
},
),
) {
ForYouScreen(onTopicClick)
}
topicDestination()
}
}
navDeepLink
: Enables navigation to a specific screen using a URI pattern.
To add a new feature module:
Create a Route Object: Define a Serializable
route object for the new module.
Extend the NavGraphBuilder: Add a function to encapsulate the navigation logic for the feature.
Update the NavHost: Add the new feature to the NiaNavHost
.
For example, adding a Profile
feature:
@Serializable object ProfileRoute
fun NavController.navigateToProfile(navOptions: NavOptions) =
navigate(route = ProfileRoute, navOptions)
fun NavGraphBuilder.profileScreen(onLogout: () -> Unit) {
composable<ProfileRoute> {
ProfileScreen(onLogout)
}
}
Modularity:
Each feature manages its own navigation.
Easy to add or remove features without affecting the core navigation logic.
Scalability:
The architecture can handle multiple features and complex navigation flows.
Maintainability:
Encapsulated logic makes it easier to debug and test individual features.
Deep Link Support:
Users can navigate directly to specific screens from notifications or external sources.
This navigation architecture leverages Jetpack Compose’s powerful NavHost
and Kotlin's modularity to create a system that is robust, scalable, and maintainable. By encapsulating navigation logic within individual modules, the architecture promotes clean code and reduces the risk of breaking changes.
Akshay Nandwana
Founder AndroidEngineers
You can connect with me on:
Join our upcoming classes
https://www.androidengineers.in/courses