Blog Infos
Author
Published
Topics
, , , ,
Published

 

As I could not find any guide for it, I have decided to do it on my own and share with you. But first it is important to check my previous article if you are not familiar with navigation 3 and want to understand how I moved from fragment navigation graph to navigation 3.

check it here: https://medium.com/proandroiddev/fragment-navigation-to-navigation-3-compose-e2ce7a5754b4

In the previous article, I replaced an entire flow using navigation 3, and after that I faced a challenge: how could I replace all the flows only using compose navigation?

I replaced a flow inside of activity, which was fine, but I don’t want to have a separate activity for each flow. I want to use sub/nested routes components and end-up having only the MainActivity, with the rest of the app in compose.

Let’s first take a look how the flows are into the app:

Sanctus App flows

As we can see, each main flow is an activity, using NavDisplay from navigation 3 to handle the flow itself. However, the goal here is no longer have flows in activities, but to have a concise flow using only compose.

How does it can be done?

There are many things that need to be changed. Let’s list it here:

1- The bottom navigation bar still uses fragment to manage each tab. First I need to replace it with compose bottom navigation.

2- Replace Tab Fragments with Compose routes screens.

3- Replace Activities flow with Compose flows using Nav3.

1 — Bottom navigation bar

Let’s quickly check how it was with fragments:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/main_coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="@dimen/margin_view">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_container"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/white"
app:itemIconTint="@drawable/bottom_navigation_colors"
app:itemTextColor="@drawable/bottom_navigation_colors"
app:labelVisibilityMode="labeled"
android:layout_gravity="bottom"
app:layout_behavior="@string/hide_bottom_view_on_scroll_behavior"
app:menu="@menu/menu_bottom_main" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

XML of the MainActivity where the fragments and the Bottom Navigation are.

private fun setupBottomNavigationBar() {
val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottom_nav)
val navGraphIds = listOf(
R.navigation.nav_graph_home,
R.navigation.nav_graph_prayers,
R.navigation.nav_graph_plans,
R.navigation.nav_grapsh_user
)
val controller = bottomNavigationView.setupWithNavController(
navGraphIds = navGraphIds,
fragmentManager = supportFragmentManager,
containerId = R.id.nav_host_container,
intent = intent
)
currentNavController = controller
}

This is the setup for linking the BottomNavigationView with the navigation graph and fragments in order to show each tab’s screen.

funny spot: findViewById — who remember that?

Each tab is a fragment: HomeFragmentPrayersFragmentPlansFragment and UserFragment.

HomeFragment: can open PhraseOfTheDaySaintOfTheDay, PrayersDetail and NovenaFlow, ChapletFlow and PlansFlow.

PrayersFragment: can open PrayersDetailNovenaFlow and ChapletFlow.

PlansFragment: one deep fragment that can access SubPlansFragment, and both can open PlansFlow.

UserFragment: it’s still under development and we are not discussing it in this article, as it does not go to any flow.

All flows are currently being opened as Activities, and we still have one nested Fragment in the Plans tab: SubPlansFragment.

Let’s check out the blue print to start this migration:

Bottom navigation from material 3:

First step — creating the routes:

sealed interface AppRoutes: NavKey {
@Serializable
data object Home : AppRoutes
@Serializable
data object Prayers : AppRoutes
@Serializable
data object Plans: AppRoutes
@Serializable
data object User : AppRoutes
}
val bottomNavItems = listOf(
AppRoutes.Home,
AppRoutes.Prayers,
AppRoutes.Plans,
AppRoutes.User
)
view raw AppRoutes.kt hosted with ❤ by GitHub

Here we have our 4 main routes, and they are marked as @Serializable because it allows them to be converted to and from JSON. This is important for saving navigation state and passing route information between different parts of the app — such as background processes or deep linking — where the route needs to be represented as data. They also extend NavKey, which is required for use with rememberNavBackStack.

And there is also the bottomNavItems, that is a list of the 4 navigations to be used in the navigation bottom bar.

Now that we have the foundation of the main routes in the app, we can check how it is implemented.

@Composable
fun NavigationBarForNavDisplay(
currentRoute: AppRoutes?,
onNavigate: (AppRoutes) -> Unit
) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.surface,
tonalElevation = SanctusDimensions.elevation
) {
bottomNavItems.forEach { screen ->
val navUtils = getNavUtils(screen)
NavigationBarItem(
icon = {
Icon(
modifier = Modifier.size(SanctusDimensions.iconSizeSmall) ,
painter = painterResource(id = navUtils.iconResource),
contentDescription = stringResource(navUtils.titleResource),
)
},
colors = NavigationBarItemDefaults.colors(
indicatorColor = MaterialTheme.colorScheme.surface,
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.primary
),
label = {
LabelMediumText(
color = MaterialTheme.colorScheme.primary,
text = stringResource(navUtils.titleResource)
)
},
selected = currentRoute == screen,
onClick = {
onNavigate(screen)
},
)
}
}
}

This is the bottom navigation bar. I’m using the bottomNavItems list to build it and, for each item, I get the necessary resources as Icon and text label. If the currentRoute is selected, it gets highlighted in the bottomNavigation. And when an item is clicked, it calls the higher-order-function onNavigation to request the navigation.

material 3 reference: https://m3.material.io/components/navigation-bar/overview

 

How it was, and how it is with material 3

2- Replace Tab Fragments with Compose routes screens.

Now, we are going to use RouteCompose Components to handle nested routes. But how? Let’s first take a look at the code and explain it:

@Composable
fun MainContent() {
val bottomBarVisibleState = remember { mutableStateOf(true) }
val backStack = rememberNavBackStack<AppRoutes>(AppRoutes.Home)
val onShowNavigationBar: (Boolean) -> Unit = { show ->
bottomBarVisibleState.value = show
}
Scaffold(
bottomBar = {
AnimatedVisibility(
visible = bottomBarVisibleState.value,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
NavigationBarForNavDisplay(
currentRoute = backStack.lastOrNull() as? AppRoutes,
onNavigate = { route ->
backStack.add(route)
}
)
}
}
) { innerPadding ->
NavDisplay(
backStack = backStack,
onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<AppRoutes.Home> {
HomeRouteComponent(onShowNavigationBar = onShowNavigationBar)
}
entry<AppRoutes.Prayers> {
PrayersRouteComponent(onShowNavigationBar = onShowNavigationBar)
}
entry<AppRoutes.Plans> {
PlansRouteComponent(onShowNavigationBar = onShowNavigationBar)
}
entry<AppRoutes.User> {
Text("User Screen Content (Route: ${AppRoutes.User})")
}
},
transitionSpec = {
slideInHorizontally(initialOffsetX = { it }) togetherWith
slideOutHorizontally(targetOffsetX = { -it })
},
popTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
},
predictivePopTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
}
)
}
}
view raw MainContent.kt hosted with ❤ by GitHub

MainComponent replaces the activity_main.xml

 

First, let’s focus on the entryProvider from NavDisplay.

Each App route will open a RouteComponent, which has his own navigation stack, completed separate from the main navigation. Why is that?

Since this is a nested navigation, it is important for the RouteComponent to manage its own internal backstack to promote modularity and encapsulation. This prevents the main backstack from growing too complex and separating it makes it easier to maintain, as each RouteComponent is isolated from the main navigation flow.

Let’s update the architecture flow:

Fully compose navigation diagram

 

Now, all screens are being routed inside of the MainActivity, which is responsible only for the main navigation backstack, which is the 4 main routes of the bottom navigation.

Let’s take a look into the HomePrayersRoute as an example:

@Composable
fun HomeRouteComponent(
onShowNavigationBar: (Boolean) -> Unit = {}
) {
val backStack = rememberNavBackStack<HomeSubRoute>(HomeSubRoute.HomeRoute)
NavDisplay(
backStack = backStack,
onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
onShowNavigationBar(backStack.lastOrNull() == HomeSubRoute.HomeRoute)
entry<HomeSubRoute.HomeRoute> {
HomeScreen(
onNovenaClicked = { novena ->
backStack.add(HomeSubRoute.NovenaRoute(novenaName = novena.name))
},
onChapletClicked = { chaplet ->
backStack.add(HomeSubRoute.ChapletRoute(chapletName = chaplet.name))
},
onLiturgyClicked = { liturgy ->
backStack.add(HomeSubRoute.LiturgyRoute)
},
onSaintClicked = { saint ->
backStack.add(HomeSubRoute.SaintRoute(saint = saint))
},
onPhraseClicked = { phrase ->
backStack.add(HomeSubRoute.PhraseOfDayRoute(phrase = phrase))
},
onPlanClicked = { plan ->
backStack.add(HomeSubRoute.PlanFlow(planName = plan.name))
},
onSubPlanClicked = { subPlan ->
backStack.add(
HomeSubRoute.PlanFlow(
planName = subPlan.planName,
subPlanName = subPlan.name
)
)
},
onPrayerClicked = { prayer ->
backStack.add(HomeSubRoute.PrayerRoute(prayerName = prayer.name))
},
)
}
entry<HomeSubRoute.LiturgyRoute> { route ->
LiturgyFlowRouteComponent(
onClose = { backStack.removeLastOrNull() }
)
}
entry<HomeSubRoute.SaintRoute> { route ->
SaintDetailScreen(
saint = route.saint,
onBackClick = { backStack.removeLastOrNull() }
)
}
entry<HomeSubRoute.PhraseOfDayRoute> { route ->
VersicleOfTheDayScreen(
phraseDay = route.phrase,
onBackClick = { backStack.removeLastOrNull() }
)
}
entry<HomeSubRoute.PrayerRoute> { route ->
PrayerDetailScreen(
prayerName = route.prayerName,
onBackClick = { backStack.removeLastOrNull() }
)
}
entry<HomeSubRoute.ChapletRoute> { route ->
ChapletFlowRouteComponent(
chapletName = route.chapletName,
onClose = { backStack.removeLastOrNull() }
)
}
entry<HomeSubRoute.NovenaRoute> { route ->
NovenaFlowRouteComponent(
novenaName = route.novenaName,
onClose = { backStack.removeLastOrNull() }
)
}
entry<HomeSubRoute.PlanRoute> {
PlanFlowRouteComponent(
planName = it.planName,
subPlanName = it.subPlanName,
onClose = { backStack.removeLastOrNull() }
)
}
},
transitionSpec = {
slideInHorizontally(initialOffsetX = { it }) togetherWith
slideOutHorizontally(targetOffsetX = { -it })
},
popTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
},
predictivePopTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
}
)
}

HomeRouteComponent is the only RouteComponent that can navigate to all flows and screens in the app. Since it is an isolated RouteComponent, it needs to manage its own backstack:

val backStack = rememberNavBackStack<HomeSubRoute>(HomeSubRoute.HomeRoute)

And for this we also need the HomeSubRoute, which holds all possible routes for this component.

@Serializable
sealed interface HomeSubRoute : NavKey {
@Serializable
data object HomeRoute : HomeSubRoute
@Serializable
data object LiturgyRoute : HomeSubRoute
@Serializable
data class SaintRoute(val saint: Saint) : HomeSubRoute
@Serializable
data class PhraseOfDayRoute(val phrase: PhraseDay) : HomeSubRoute
@Serializable
data class ChapletRoute(val chapletName: String) : HomeSubRoute
@Serializable
data class PrayerRoute(val prayerName: String) : HomeSubRoute
@Serializable
data class NovenaRoute(val novenaName: String) : HomeSubRoute
@Serializable
data class PlanRoute(
val planName: String,
val subPlanName: String? = null
) : HomeSubRoute
}
view raw HomeSubRoute.kt hosted with ❤ by GitHub

With everything defined we can take a look in each Route:

HomeRoute, is the starting point and can navigate to any flow or screen in the app. When it is current route in the backstack, we call onShowNavigationBar to display the bottomNavigationBar. If any other screen is active, the navigation bar will be hidden (take a look at the MainContent to understand a bit more)

SaintRoutePhraseOfTheDayRoute and PrayerRoute only navigate to a new screen in the backstack. These screens do not contain any nested RouteComponent.

LiturgyRoute, ChapletRoute, NovenaRoute and PlanRoute navigate to another RouteComponent, each of which manages its own internal navigation backstack.

Each Route is isolated and reusable in any navigation call, because it doesn’t depend on anything external to the component, what makes it very scalable and easy to maintain.

Whenever a route is removed from the backstack, all of its own resources are destroyed. In app rotation, for example, the resources will remain on the current screen. Thanks to the rememberNavBackStack, we can also use a simple mutable list in the ViewModel to handle it, but it is really optional. According to the official documentation, either approach is valid.

3- Replace Activities flow with Compose flows using Nav3

Now, I’ll show the ChapletRouteComponent which I already used as an example of migrating from fragment navigation to nav3 in my previous article. Back then, I using activity there, so currently we can see the transformation using only compose and nav3:

@Composable
fun ChapletFlowRouteComponent(
viewModel: ChapletFlowViewModel = koinViewModel(),
chapletName: String,
onClose: () -> Unit
) {
val backStack = viewModel.backStack
LaunchedEffect(Unit) {
viewModel.shouldCloseFlow.collect { shouldClose ->
if (shouldClose) {
onClose()
}
}
}
NavDisplay(
backStack = backStack,
onBack = { onClose() },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<ChapletRoutes.Start> {
ChapletStartStepScreen(
onPrayStart = {
viewModel.navigateTo(ChapletRoutes.Pray(chapletName = chapletName))
}
)
}
entry<ChapletRoutes.Pray> {
ChapletPrayScreen(
chapletName = it.chapletName,
onClose = onClose,
onFinish = {
viewModel.navigateTo(ChapletRoutes.Feedback)
}
)
}
entry<ChapletRoutes.Feedback> {
ChapletFeedbackScreen(
onFinish = {
viewModel.onChapletDone()
}
)
}
},
transitionSpec = {
slideInHorizontally(initialOffsetX = { it }) togetherWith
slideOutHorizontally(targetOffsetX = { -it })
},
popTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
},
predictivePopTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
}
)
}

It looks almost the same as the ChapletActivityFlow. If you do not remember, here is the gist from the previous article:

class ChapletFlowActivity : ComponentActivity() {
private val viewModel: ChapletFlowViewModel by viewModel()
companion object {
const val EXTRA_CHAPLET_NAME = "extra_terco_object_key"
fun newIntent(context: Context, chapletName: String): Intent {
return Intent(context, ChapletFlowActivity::class.java).apply {
putExtra(EXTRA_CHAPLET_NAME, chapletName)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val chapletName = intent?.getStringExtra(EXTRA_CHAPLET_NAME)
?: run {
toast(getString(R.string.error_loading))
finish()
return
}
viewModel.initializeFlow(chapletId)
setContent {
SanctusAppTheme {
SetupNavigation()
}
}
}
@Composable
private fun SetupNavigation() {
val backStack = viewModel.backStack
BackHandler(enabled = backStack.size > 1) {
viewModel.popBackStack()
}
NavDisplay(
backStack = backStack,
onBack = {
if (!viewModel.popBackStack()) {
finish()
}
},
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<ChapletStartStep> {
ChapletStartStepScreen(
onPrayStart = {
viewModel.onPrayStart()
}
)
}
entry<ChapletPray> {
ChapletPrayScreen(
chapletName = it.chapletName,
onClose = {
finish()
},
onFinish = {
viewModel.onPrayFinish()
}
)
}
entry<ChapletDone> {
ChapletFeedbackScreen(
onFinish = {
finish()
}
)
}
},
transitionSpec = {
slideInHorizontally(initialOffsetX = { it }) togetherWith
slideOutHorizontally(targetOffsetX = { -it })
},
popTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
},
predictivePopTransitionSpec = {
slideInHorizontally(initialOffsetX = { -it }) togetherWith
slideOutHorizontally(targetOffsetX = { it })
}
)
}
}

The main difference is that there are no activities anymore, as it is 100% compose component, which controls its own backstack. In this case, the backstack is in the ViewModel. You can ask: why are you using rememberNavBackStack in some places and ViewModel in others? Just because I wanted to demonstrate that both approaches are valid :D.

class ChapletFlowViewModel(
private val challengeRepository: ChallengeRepository
) : ViewModel() {
private val _shouldCloseFlow = MutableSharedFlow<Boolean>()
val shouldCloseFlow: SharedFlow<Boolean> = _shouldCloseFlow
val backStack = mutableStateListOf<ChapletRoutes>()
init {
if (backStack.isEmpty()) {
backStack.add(ChapletRoutes.Start)
}
}
fun navigateTo(route: ChapletRoutes) {
backStack.add(route)
}
fun onChapletDone() {
viewModelScope.launch {
challengeRepository.updateChallengeV2(ChallengeId.CHAPLETS)
_shouldCloseFlow.emit(true)
}
}
}

ChapletFlowViewModel is just to manage the backstack of this flow and update the challenges once the user is done with chaplet.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Conclusion and point of attention:

As you’ve seen, this approach follows the single responsibly pattern, where each route is responsible for managing its own navigation stack. This makes the architecture much easier to maintain, as we don’t need to handle crazy navigation stuff, because each route manages its own backstack.

It also survives configuration changes, whether you’re using backstack in the ViewModel or rememberNavBackStack.

When a route is removed from the backstack, all of its resources are destroyed, preventing memory leaks.

And it’s also important to revisit the role of entryDecorators, which I will copy from my previous article:

entryDecorators:

  • rememberSceneSetupNavEntryDecorator: it intelligently manages screen content, allowing it to move without costly recompositions. This results in better performance and more reliable UI behavior during transitions.
  • rememberSavedStateNavEntryDecorator: It provides the necessary infrastructure within the “Navigation 3” framework so that rememberSaveable works as intended on a per-screen basis. It isolates the saved state logic for each screen, preventing conflicts and ensuring that user progress and UI state are reliably preserved and restored. This is a fundamental piece for building apps that handle system-initiated process death and configuration changes gracefully
  • rememberViewModelStoreNavEntryDecorator: To ensure that screen-specific data managers (ViewModels) are correctly tied to each screen’s lifecycle, this function is essential. It enables tools like Koin (in our case) to provide fresh, correctly scoped ViewModels for every screen, preventing data conflicts and ensuring proper cleanup. This results in more robust and predictable screen behavior.

Let’s take a look and compare how it is now and how it was before:

 

Extra:

I’m sharing the code of PrayersRouteComponent just to show how easy it is to “Plug” a route into any other component:

@Composable
fun PrayersRouteComponent(
onShowNavigationBar: (Boolean) -> Unit = {}
) {
val backStack = rememberNavBackStack<PrayersSubRoute>(PrayersSubRoute.PrayersRoute)
NavDisplay(
backStack = backStack,
onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
onShowNavigationBar(backStack.lastOrNull() == PrayersSubRoute.PrayersRoute)
entry<PrayersSubRoute.PrayersRoute> {
PrayersScreen(
onPrayerClicked = { prayer ->
backStack.add(PrayersSubRoute.PrayerRoute(prayerName = prayer.prayer.name))
},
onNovenaClicked = { novena ->
backStack.add(PrayersSubRoute.NovenaRoute(novenaName = novena.novena.name))
},
onChapletClicked = { chaplet ->
backStack.add(PrayersSubRoute.ChapletRoute(chapletName = chaplet.terco.name))
}
)
}
entry<PrayersSubRoute.PrayerRoute> { route ->
PrayerDetailScreen(
prayerName = route.prayerName,
onBackClick = { backStack.removeLastOrNull() }
)
}
entry<PrayersSubRoute.ChapletRoute> { route ->
ChapletFlowRouteComponent(
chapletName = route.chapletName,
onClose = { backStack.removeLastOrNull() }
)
}
entry<PrayersSubRoute.NovenaRoute> { route ->
NovenaFlowRouteComponent(
novenaName = route.novenaName,
onClose = { backStack.removeLastOrNull() }
)
}
},
.....
transitionSpec,
popTransitionSpec,
predictivePopTransitionSpec,
)
}

This is the PrayersRouteComponent and it can navigate to: PrayerDetailScreen, ChapletFlowRouteComponent and NovenaFlowRouteComponent.

It’s very easy. We can just call RouteComponents from other route components without any side effects.

If you have any questions, feel free to message me here or reach out on Linkedin.

This article was previously published on proandroiddev.com.

Menu