I’ve already migrated one flow of the Sanctus App to Jetpack Compose, although the navigation part hasn’t been updated yet. So, I’ve decided to experiment with it next.
How it is currently implemented:
I was using the old navigation fragment library, where each feature in the app had its own Activity flow. Each of these Activities hosted a navigation graph that managed the flow between fragment screens for that feature. Let’s take a look into the ChapletFlow(old Terco flow) graph:

screenshot from nav_graph of Chaplet feature
And the XML that generates it:
And in the xml of TercoFlowActivity, I just had to handle each navigation option, but first setting up the nav graph in the activity_terco_flow.xml:
| <?xml version="1.0" encoding="utf-8"?> | |
| <LinearLayout | |
| xmlns:android="http://schemas.android.com/apk/res/android" | |
| xmlns:app="http://schemas.android.com/apk/res-auto" | |
| android:orientation="vertical" | |
| android:layout_width="match_parent" | |
| android:layout_height="match_parent"> | |
| <fragment | |
| android:id="@+id/navChapletContainer" | |
| android:name="androidx.navigation.fragment.NavHostFragment" | |
| android:layout_width="match_parent" | |
| android:layout_height="match_parent" | |
| app:defaultNavHost="true" | |
| app:navGraph="@navigation/nav_graph_chaplet"/> | |
| </LinearLayout> |
Now in the activity:
| class TercoFlowActivity : BaseActivityX(R.layout.activity_terco_flow) { | |
| private val tercoFlowViewModel : TercoFlowViewModel by viewModel() | |
| private val terco: Terco by lazy { intent?.getSerializableExtra(TERCO) as Terco } | |
| private lateinit var navigationController: NavController | |
| companion object { | |
| const val TERCO = "terco" | |
| fun newIntent(context: Context, terco: Terco): Intent { | |
| val intent = Intent(context, TercoFlowActivity::class.java) | |
| intent.putExtra(TERCO, terco) | |
| return intent | |
| } | |
| } | |
| override fun onCreate(savedInstanceState: Bundle?) { | |
| super.onCreate(savedInstanceState) | |
| navigationController = findNavController(R.id.navChapletContainer) | |
| setupViewModel() | |
| } | |
| private fun setupViewModel() { | |
| tercoFlowViewModel.chapletStateLiveData.observe(this, Observer { | |
| it.getContentIfNotHandled()?.let { state -> | |
| when(state) { | |
| ChapletState.StartStep -> { | |
| // First step | |
| } | |
| ChapletState.Finish -> finish() | |
| ChapletState.Detail -> { | |
| val bundle = TercoDetailFragment.bundle(terco) | |
| navigationController.navigate(R.id.action_tercoStartStepFragment2_to_tercoDetailFragment, bundle) | |
| } | |
| ChapletState.FeedBack -> { | |
| navigationController.navigate(R.id.action_tercoDetailFragment_to_tercoFeedBackFragment) | |
| } | |
| is ChapletState.LevelUp -> { | |
| val bundle = ChallengeLevelUpFragment.bundle( | |
| newLevel = state.newLevel, coins = state.coins, challengesRules = state.challengesRules | |
| ) | |
| navigationController.navigate(R.id.action_tercoDetailFragment_to_challengeWinDetailFragment, bundle) | |
| } | |
| } | |
| } | |
| }) | |
| } | |
| } |
Each screen is defined as a type of ChapletState in the ViewModel, so once a new state is triggered in the chapletStateLiveData, it will navigate to a different fragment screen from the nav graph.
Now that you get a on overview about the old/current implementation, let’s check how to replace it with nav 3 library.
Curiosity: I have developed this 10/12/2019 +- 6 years ago, and we will se why a good architecture decision made in the past will pay off for the upgrade to nav 3
Nav 3 implementation:
Library Setup:
Added dependencies to lisb.version.toml.
| # Navigatio libraries versions | |
| nav3Core = "1.0.0-alpha01" | |
| lifecycleViewmodelNav3 = "1.0.0-alpha01" | |
| # Navigatio libraries | |
| androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } | |
| androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } | |
| androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } |
Upgraded compileSdk to 36, and then added those libraries to the build.gradle of the chaplet module:
| implementation(libs.androidx.navigation3.ui) | |
| implementation(libs.androidx.navigation3.runtime) | |
| implementation(libs.androidx.lifecycle.viewmodel.navigation3) |
Now I’m ready to use navigation 3 in this module.
ViewModel:
I used to call the ViewModel of the chaplet flow as TercoFlowViewModel but I have decide to update it to use Chaplet as the feature name, and also changed a lot of other classes and definitions to use the same.
The ChapletFlowViewModel, holds all states related to the screens.
I used to have this:
| sealed class ChapletState{ | |
| object StartStep: ChapletState() | |
| object Detail: ChapletState() | |
| object Finish: ChapletState() | |
| object FeedBack: ChapletState() | |
| data class LevelUp(val newLevel: Int, val coins: Int, val challengesRules: ChallengesRules): ChapletState() | |
| } |
and a live data that exposed those states to the UI:
| private val _chapletStateLiveData = MutableLiveData<ChapletState>() | |
| val chapletStateLiveData: LiveData<ChapletState> = _chapletStateLiveData |
I could just keep using it, but I have decided to create a new sealead interface:
| sealed interface ChapletRoutes { | |
| data object Start: ChapletRoutes | |
| data class Pray(val chapletName: String): ChapletRoutes | |
| data object Done: ChapletRoutes | |
| } |
Those are all the routes that I can have in my navigation backstack.
But what is this navigation backstack all about? To that end, I will explain the whole new implementation of the ChapletFlowViewModel.
| class ChapletFlowViewModel( | |
| val challengeUseCase: ChallengeUseCase | |
| ) : BaseViewModel() { | |
| private val _chapletName = MutableStateFlow<String?>(null) | |
| val backStack = mutableStateListOf<ChapletRoutes>() | |
| init { | |
| if (backStack.isEmpty()) { | |
| backStack.add(ChapletStartStep) | |
| } | |
| } | |
| fun initializeFlow(chapletName: String) { | |
| if (_chapletName.value == chapletName) { | |
| return | |
| } | |
| _chapletName.value = chapletName | |
| } | |
| fun navigateTo(route: ChapletRoutes) { | |
| backStack.add(route) | |
| } | |
| fun onPrayStart() { | |
| _chapletName.value?.let { | |
| navigateTo(ChapletPray(chapletName = it)) | |
| } | |
| } | |
| fun onPrayFinish() { | |
| if (backStack.isNotEmpty() && backStack.last() is ChapletPray) { | |
| backStack[backStack.lastIndex] = ChapletDone // Replace | |
| } else { | |
| navigateTo(ChapletDone) | |
| } | |
| } | |
| fun popBackStack(): Boolean { | |
| return if (backStack.size > 1) { | |
| backStack.removeLastOrNull() != null | |
| } else { | |
| false // Cannot pop the last item, signals activity to finish or handle | |
| } | |
| } | |
| } |
I have followed the google docs recommendation of using nav 3.
It suggests maintaining a backstack that holds references to the content screens as keys, allowing the feature itself to manage its own navigation state.
Since it is a mutableStateListOf, any operation that occurs on it will automatically be reflected wherever it’s used (I’ll show this part later in the activity). So, you can see from the implementation that I decided to keep it in the viewModel instead of the Activity, but why?
Because it survives configuration changes (such as screen rotations) that destroy and recreate the Activity, and ensures that the navigation state is preserved.
The same concept applies to the chapletName.
I have an initialization of the flow, setting the name of the chaplet, if it is not already there, and I also set the first route in the backstack in the init block.
Then I have a couple of things:
navigateTo: just add a Chaplet route in the backstack.
onPrayStart: ensure that Chaplet name is defined and then add the ChapletPray to the backstack.
onPrayFinish: if the flow is done it navigate to the last route ChapletDone, if it is not in the backstack, otherwise, make it the last item of the backstack.
popBackStack: this is just to handle back press from the user, to ensure that there is no routes remaining in the backstack before finishing the flow.
Obs: once I have all features migrated to navigation 3, I will create a new navigation module where I will have all Routes. I will then remove this backstack from this feature viewModel, but fornow, it is treated as a isolated feature, and I will scalate it as we go.
Activity:
Now that the ViewModel is setup with the new approach, we can check the implementation of the ChapletFlowActivity.
| 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 }) | |
| } | |
| ) | |
| } | |
| } |
onCreate: first I check if the chapletName is there. If not, I show a message and finish the activity,. If yes, I call the setup of the Navigation.
SetupNavigation: this is called within the SanctusTheme, to use all the application’s theme definitions.
BackHandler: it is a standard composable function to intercept the system back button press, and in my case, it only allows the back button to open a screen if there is more than one screen in the backstack.
NavDisplay: custom composable component from navigation 3 library which is the main container of this flow that displays the current screen based on the backstack and manage the transitions between screens.
(backstack: backstack): passes the backstack defined in the ViewModel to the NavDisplay, which tells which screens are in the history and which one is currently being displayed..
NavDisplay(onBack = {//check ChapletFlowActivity file above} ):
is a callback, which is triggered when an internal backstack action occurs. In this case, it checks if there is something to remove from the backstack, otherwise, it ends the Activity.
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.
entryProvider: defines the navigation graph for the chaplet flow resource, mapping navigation routes (ChapletRoutes) to the desired composite screen
transitionSpec: defines the animation when navigating to a new screen, sliding it from the right, while current screen slides out to the left.
popTransitionSpec: defines the animation when navigating back to previous screen, previous screen slide back in from the left, while current screen slides out to the right.
predictivePopTransitionSpec: defines the animation specifically for Android predictive gesture (Android 13+). When user swipe back, it mirrors the same behaviour of popTransitionSpec.
A very important point that cannot be overlooked is that I replaced each fragment in the navigation graph with a Compose UI screen component. I wouldn’t say the transition was entirely smooth, even though I used the same approach by treating each screen as a state that points to a Compose screen instead of a fragment.
obs: before migrating to nav3, I first updated each fragment to use compose and validated, when everything was ok, I migrated to navigation update:
Step1 — XML -> Compose: keeping the current fragment but implementing a compose view inside
| class TercoDetailFragment : BaseFragmentX(R.layout.fragment_terco_detail) { | |
| private lateinit var binding: FragmentTercoDetailBinding | |
| private val terco: Terco by lazy { arguments?.getSerializable(TERCO) as Terco } | |
| companion object { | |
| const val TERCO = "terco" | |
| fun bundle(terco: Terco): Bundle { | |
| return bundleOf(TERCO to terco) | |
| } | |
| } | |
| override fun onCreateView( | |
| inflater: LayoutInflater, | |
| container: ViewGroup?, | |
| savedInstanceState: Bundle? | |
| ): View { | |
| binding = FragmentTercoDetailBinding.inflate(layoutInflater) | |
| return binding.root | |
| } | |
| override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | |
| super.onViewCreated(view, savedInstanceState) | |
| val composeView = binding.composeView | |
| composeView.setContent { | |
| SanctusAppTheme { | |
| ChapletPrayScreen( | |
| chaplet = terco | |
| ) | |
| } | |
| } | |
| } | |
| } |
Step2 — navigation-fragment -> nav3 compose: once I had all steps implemented in Compose, I could delete the fragments and only use the compose screens.
| entry<ChapletRoutes.Pray> { | |
| ChapletPrayScreen( | |
| chapletName = it.chapletName, | |
| onClose = { | |
| finish() | |
| }, | |
| onFinish = { | |
| viewModel.onPrayFinish() | |
| } | |
| ) | |
| } |
Job Offers
Obs: always try to update step by step, to not have a big chunk of changes at once.
Final result:

We can observe that there is a huge improvement in terms of how fluid it was and how it is now. It is just incredible how good the new implementation is. And of course, as you can observe, I have used a lot of animations in the Jetpack Compose version to show how nice an app can be with not too much effort, I would say, because Compose UI is very straightforward in terms of animations.
Check out the Sanctus App in the PlayStore:
https://play.google.com/store/apps/details?id=com.evangelhododiacatolico
This article was previously published on proandroiddev.com.



