Tomáš Repčík - 3. 12. 2022

Android Jetpack Compose and Navigation

A simple example of using NavHost and NavController

With Jetpack Compose, Android is abandoning implicit ways of moving around with fragment transactions, navigations and intents for activities. Nowadays, the app should declare all the possible paths users can take at the beginning.

Jetpack compose provides a native way how to navigate through your multiple components. With NavHost and NavController, you can push or pop your composables as you need. So let’s get to it.

Photo by Brendan Church on Unsplash

Dependencies

Firstly, you will need to add a dependency to your project. To get the current version, go here.

implementation "androidx.navigation:navigation-compose:2.5.3"

Going from screen to screen and back

Implementation

Firstly, NavHost and NavController are introduced to the project.

NavHost hosts the declaration of the routes which users can take. NavController provides API to move around the declared routes. Route is path, which is unique for each screen/other composable which could be shown in the app.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            NavigationExampleTheme {
                val navController = rememberNavController()
                NavHost(navController = navController, startDestination = "FirstScreen") {
                    composable("FirstScreen") {
                        FirstScreen(navigation = navController)
                    }
                    composable("SecondScreen") {
                        SecondScreen(navigation = navController)
                    }
                }
            }
        }
    }
}

Instantiation

NavController is instantiated with rememberNavController(), so even upon the rebuild of composables, the NavController is the same.

Starting destination

The NavHost with NavController and starting destination are declared. Starting destination is the name of our first screen route.

Declaration of routes

All the options are declared where an user can move. Both screens need to declare routes. The route name is unique because the NavController will use them to identify the next composable to show.

@Composable
fun FirstScreen(navigation: NavController) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("First screen")
        Button(onClick = {
            navigation.navigate("SecondScreen")
        }) {
            Text("Go to second screen")
        }
    }
}
@Composable
fun SecondScreen(navigation: NavController) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Second screen")
        Button(onClick = {
            navigation.popBackStack()
        }) {
            Text(text = "Back to first screen")
        }
    }
}

Code of the two screens

Navigation between two screens

Any composable can be shown via the navcontroller.navigate(“route to composable”).

To mimic the back button of the user, the navcontroller.popBackStack() can be used. This will pop out the current composable from the stack of all composable.

Stack can be viewed as a history of the composables/screens, so first screen is the root and other composable is put on top of it. So by navigating to second screen, the second screen is added on top of the stack. By popping out the stack, the top composable is removed.

Clean up

To be more error-prone in the future, paths should be declared more cleanly. So avoid adding strings everywhere and create the standalone file where you put all the routes for your app. Afterwards, the declared constants should be used. Embarrassing typos will be avoided in the future. Moreover, it provides a more descriptive view of how the app is structured.

object Routes {
    const val FIRST_SCREEN = "FirstScreen"
    const val SECOND_SCREEN = "SecondScreen"
}

Going through multiple screens and back to the root

From now on adding subsequent screens to our navigation is easy. The NavHost needs to be extended by one more composable, and screens need to adopt more buttons to move around.

The third screen contains a button, which gets the user back to the first screen and cleans all the stacked components.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            NavigationExampleTheme {
                val navController = rememberNavController()
                NavHost(navController = navController, startDestination = Routes.FIRST_SCREEN) {
                    composable(Routes.FIRST_SCREEN) {
                        FirstScreen(navigation = navController)
                    }
                    composable(Routes.SECOND_SCREEN) {
                        SecondScreen(navigation = navController)
                    }
                    composable(Routes.THIRD_SCREEN) {
                        ThirdScreen(navigation = navController)
                    }
                }
            }
        }
    }
}
@Composable
fun FirstScreen(navigation: NavController) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("First screen")
        Button(onClick = {
            navigation.navigate(Routes.SECOND_SCREEN)
        }) {
            Text("Go to second screen")
        }
    }
}
@Composable
fun SecondScreen(navigation: NavController) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Second screen")
        Button(onClick = {
            navigation.popBackStack()
        }) {
            Text(text = "Back to first screen")
        }
        Button(onClick = {
            navigation.navigate(Routes.THIRD_SCREEN)
        }) {
            Text(text = "Go to third screen")
        }
    }
}

@Composable
fun ThirdScreen(navigation: NavController) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Third screen")
        Button(onClick = {
            navigation.popBackStack()
        }) {
            Text(text = "Back to second screen")
        }
        Button(onClick = {
            navigation.popBackStack(Routes.FIRST_SCREEN, false)
        }) {
            Text(text = "Back to first screen")
        }
    }
}

To pop multiple composables in the stack, navController.popBackStack(“Screen where you want to stop the popping”, inclusive: false) can be used.

The inclusive parameter tells us if the targeted composable should be removed too. Removing the first screen would result in a blank screen, so the false value is passed.

Passing value to the screen

Most apps want to show information or data dependent on the user’s previous choice. Another screen needs to know what to show, so value needs to be passed.

This is done by putting the value inside the route, so the value is encoded as a string and then parsed at another screen.

Declaration

Firstly, the route needs to declare the presence of the value, which is done like this:

const val SECOND_SCREEN = "SecondScreen/{customValue}"  
// where the {customValue} is placeholder for the passed value

Add the argument to composable too:

composable(  
    Routes.SECOND_SCREEN,  
    arguments = listOf(navArgument("customValue") {  
        type = NavType.StringType  
    })  
) { backStackEntry ->  
    ThirdScreen(  
        navigation = navController,  
        // callback to add new composable to backstack   
        // it contains the arguments field with our value  
        textToShow = backStackEntry.arguments?.getString(  
            "customValue", // key for our value  
            "Default value" // default value  
        )  
    )  
}

Next, the value needs to be passed to the screen by formatting the value inside of the path like this and calling navController with it:

val valueToPass = "Information for next screen"  
val routeToScreenWithValue = "SecondScreen/$valueToPass"  
navigation.navigate(routeToScreenWithValue)

Example

The first screen now contains one text field, which contents are passed to the second screen.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            NavigationExampleTheme {
                val navController = rememberNavController()
                NavHost(navController = navController, startDestination = Routes.FIRST_SCREEN) {
                    composable(Routes.FIRST_SCREEN) {
                        FirstScreen(navigation = navController)
                    }
                    composable(
                        Routes.SECOND_SCREEN,
                        arguments = listOf(navArgument(Routes.Values.SECOND_SCREEN_VALUE) {
                            type = NavType.StringType
                        })
                    ) { backStackEntry ->
                        SecondScreen(
                            navigation = navController,
                            textToShow = backStackEntry.arguments?.getString(
                                Routes.Values.SECOND_SCREEN_VALUE,
                                "Default value"
                            )
                        )
                    }
                }
            }
        }
    }
}
@Composable
fun FirstScreen(navigation: NavController) {

    var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("First screen")
        TextField(
            value = textFieldValue,
            onValueChange = { newText ->
                textFieldValue = newText
            }
        )
        Button(onClick = {
            navigation.navigate(Routes.getSecondScreenPath(textFieldValue.text))
        }) {
            Text(text = "Go to second screen")
        }
    }
}
object Routes {
    
    const val FIRST_SCREEN = "FirstScreen"
    const val SECOND_SCREEN = "SecondScreen/{${Values.SECOND_SCREEN_VALUE}}"

    fun getSecondScreenPath(customValue: String?): String =
        // to avoid null and empty strings
        if (customValue != null && customValue.isNotBlank()) "SecondScreen/$customValue" else "SecondScreen/Empty"

    object Values {
        const val SECOND_SCREEN_VALUE = "customValue"
    }
}
@Composable
fun SecondScreen(navigation: NavController, textToShow: String?) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .fillMaxHeight(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Second screen")
        Text("Text from previous screen: $textToShow")
        Button(onClick = {
            navigation.popBackStack()
        }) {
            Text(text = "Back to first screen")
        }
    }
}

Do not pass complex data to views in the navigation

Only one value should be passed to the view. Other data should be obtained with the help of the ViewModel or other logic unit. It will make your code much cleaner and more testable.

For example, if you want to show details about a product or user, an ID should be passed to the view only. The logic unit obtains the ID and digs up more detailed information from the internet or a database. The data are then passed to the view directly without the help of the navigation components.

Resources:

1. Android compose documentation

Subscribe for more
LinkedIn GitHub Medium Threads X Bluesky