Menu

Navigation in Jetpack Compose Pt. 1

Recently I was tasked with thinking of a way to integrate Jetpack compose (the modern way of android development) in our current code base where we rely on Kotlin & XML, with java sprinkled here and there. Problem is the way we replace our fragments are now considered unconventional as we currently use a FrameLayout to replace our current fragment. Yes, we are behind, how behind you may ask? Behind enough where Navigation Graphs were not  considered until now as a replacement for handling our xml screens. Because we have so much code, our approach will be to create new screens using Jetpack Compose while simultaneously tackling our current navigation problem. I found 3 ways to accomplish that.

Option 1:

First question I had to ask myself. Could we go with a 100% Jetpack compose solution starting at the root? To my surprise, we could (sorta) do that if we nested navigation graphs together. At the root we would have a Jetpack Compose NavGraph, for instance say for our login, register views, then contain a reference to another navigation layer that would handle our other views (ie. In a  compose BottomNav). The issue with this here is that a lot of our layouts are in Fragments and Compose NavGraphs doesn't work with fragments. We could go as far as having a reference to the fragment layout view via the AndroidView then access the FragmentContainerView to handle our fragment navigation (See Option 2)

Option One, All our fragments are converted to Jetpack Compose Views so here is an example of a nested navigation coming from our MainActivity calling: setContent { Root() }

@Composable
fun Root() {
    val navController: NavHostController = rememberNavController()
    NavHost(navController = navController, startDestination = "auth") {
        composable(route = "about") { AboutView() }
        navigation(startDestination = "login", route = "auth") {
            composable(route = "login") {
                Button(
                    modifier = Modifier.padding(16.dp).fillMaxWidth(),
                    onClick = {
                        navController.navigate(route = "overview") {
                            popUpTo(route = "auth") {
                                // A, B[Our Nav Graph Here], C, D[Our Screen Is Here]  -> E
                                // (overview)
                                // A -> E
                                inclusive =
                                    true // it would also pop this current view out the backstack
                            }
                        }
                    }
                ) {
                    Text(
                        text = "Example",
                        modifier = Modifier.padding(16.dp).wrapContentHeight(),
                        fontSize = 22.sp
                    )
                }
            }
            composable(route = "register") { RegisterView() }

            composable(route = "forgot_password") { ForgotPasswordView() }
        }
        navigation(startDestination = "main", route = "overview") {
            composable("main") { MainView() }
        }
    }
}

And finally in MainView() we have our nested navigation.

@Composable
fun MainView() {
    val navController = rememberNavController()
    scaffold(navController = navController) {
        NavHost(navController = navController, TabItem.Home.route) {
            composable(TabItem.Home.route) { HomeView() }
            composable(TabItem.Consoles.route) { ConsolesView() }
            composable(TabItem.Packages.route) { RepositoryView() }
            composable(TabItem.Ftp.route) { FTPView() }
            composable(TabItem.Settings.route) { SettingsView() }
        }
    }
}

scaffold -> contains top and bottom navbar as we dont wont this to be in the root of our app but only on our auth page

@Composable
fun scaffold(navController: NavHostController, builder: ComposableFun) {
    Scaffold(
        topBar = { TopBar() },
        bottomBar = { BottomNavigationBar(navController) },
        content = { padding ->
            Box(modifier = Modifier.padding(padding)) { builder() }
        },
        backgroundColor = Color.White 
    )
}


Option 2:

Can we invert option 1? In other words can we use an xml nav graph as the root and have each fragment handle their own compose nav graph? Yes, and to me this seem like the most viable solution and something more attainable. Even if we have the fragment handle their own compose nav graph, we can still use a nested xml nav graph and still write our compose views inside meaning that we can maintain our fragments but handle navigation the following way allowing our fragments to still utilize jetpack compose or legacy xml views.

@AndroidEntryPoint
class MainActivityXml : AppCompatActivity() {

    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PreviewHomeFragmentView() { binding ->
                this.navHostFragment =
                    supportFragmentManager.findFragmentById(R.id.fragment_container)
                        as NavHostFragment
                setSupportActionBar(binding.bottomAppBar)
                NavigationUI.setupWithNavController(binding.bottomNavigation, navController)
                navController.addOnDestinationChangedListener(onDestinationChanged(binding))
            }
        }
    }
}

@Composable
@Preview(showBackground = true)
fun PreviewHomeFragmentView(binding: ((binding: ActivityMainXmlBinding) -> Unit)? = null) {
    AndroidViewBinding(ActivityMainXmlBinding::inflate) { binding?.invoke(this) }
}

@Composable
@Preview(showBackground = true)
fun PreviewHomeFragmentView2() {
    AndroidView(
        factory = { ctx ->
            val inflate = LayoutInflater.from(ctx).inflate(R.layout.activity_main_xml, null)
            inflate
        }
    ) {}
}

Our NavGraph:

<navigation
    android:id="@+id/nav_graph_compose"
    app:startDestination="@id/fragment_home_view">

	<fragment
        android:id="@+id/fragment_home_view"
        android:name="io.vonley.mi.ui.screens.home.HomeFragmentView"
        android:label="Consoles">
		<action
            android:id="@+id/action_permissions"
            app:destination="@id/fragment_permissions"
            app:popUpTo="@id/fragment_home_view"
            app:popUpToInclusive="true" />
	</fragment>

	...

	<fragment
        android:id="@+id/fragment_settings_view"
        android:name="io.vonley.mi.ui.screens.settings.SettingsFragmentView"
        android:label="Settings">
		<argument
            android:name="remote_root_directory"
            app:argType="string" />
	</fragment>

</navigation>

Example of our compose fragments:

@AndroidEntryPoint
class HomeFragmentView : Fragment() {
    override fun onCreateView(_: LayoutInflater, _: ViewGroup?, _: Bundle?): View {
        return ComposeView(requireContext()).apply { setContent { HomeView() } }
    }
}

@Composable
fun HomeView() {
    Column() { ArticleList() { LogList() } }
}

@Composable
fun LogList() {
    Text(
        text = "Logs", color = Color.Black, modifier = Modifier.padding(16.dp, 8.dp),
        fontWeight = FontWeight.Bold, fontStyle = FontStyle.Normal, fontSize = 20.sp
    )
    LogCardBase(
        title = "Connect to http://192.168.11.248:8080", 
        description = "", 
        background = Mi.Color.QUATERNARY
    )
}

@Composable
inline fun ArticleList(content: @Composable () -> Unit) {
    Column(modifier = Modifier.verticalScroll(state = ScrollState(0))) {
        Mi.ARTICLES.forEach { ArticleCard(article = it) }
        content()
    }
}

The reason why I prefer Option 2 is because it allows us to map our fragments knowing where each fragment will lead us. This approach makes it clear and concise where things are designed to go. This begs into question, can we navigate between compose and xml and still maintain the state?