Using Screen Navigation with Compose

In this guide, we deep dive into Compose navigation to help establish efficient navigation flows for your Android apps.
Using Screen Navigation with Compose

Moving between screens and views is a crucial aspect of creating robust and user-friendly Android applications. The approach to navigation has been totally transformed with the advent of Jetpack Compose, Google's modern declarative UI toolkit.

Managing navigation effectively can be a daunting task, especially when it involves passing data between screens. So in this guide, we will dive into Compose navigation to help establish efficient navigation flows for your Android apps.

To illustrate this, we’ll construct a basic app with 3 distinct screens. The app will feature a Home Screen with a button to navigate to the Profile Screen. Additionally, the Home Screen will include a list of clickable items that lead to a detail screen. We’ll develop the app using Compose and Koin - straightforward library for injecting dependencies and viewModels.

Getting Started

Let's kick off by incorporating the necessary dependencies in build.gradle.kts.


implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1")

val composeNavVersion = "2.7.7"

implementation("androidx.navigation:navigation-compose:$composeNavVersion")

val koinVersion = "3.5.6"

implementation("io.insert-koin:koin-core:$koinVersion")

implementation("io.insert-koin:koin-android:$koinVersion")

implementation("io.insert-koin:koin-androidx-compose:$koinVersion")

Building the UI

Home Screen

The home screen is a very simple one that inputs a list of items and functions as the user taps on the button to navigate to the profile and item screens.

@Composable
fun HomeScreen(items: List<ItemData>, onTapProfile: () -> Unit, onTapItem: (ItemData) -> Unit) {

    Box(
        Modifier.fillMaxSize().background(Color(0xFF3F51B5)).safeDrawingPadding(),
        Alignment.Center
    ) {
        LazyColumn {
            item {
                Box(Modifier.fillMaxWidth(), Alignment.Center) {
                    Button(
                        modifier = Modifier.fillMaxWidth().padding(20.dp),
                        onClick = { onTapProfile() }
                    ) {
                        Text("Go to Profile")
                    }
                }
            }

            item {
                Text(
                    "Items : ",
                    fontSize = 30.sp,
                    color = Color.White,
                    modifier = Modifier.padding(10.dp)
                )
            }

            items(items) { item ->
                Column(
                    Modifier.fillMaxWidth()
                        .height(50.dp)
                        .background(Color(item.color))
                        .clickable { onTapItem(item) }
                        .padding(horizontal = 20.dp)
                ) {
                    Spacer(Modifier.size(8.dp))

                    Text("Item  #${item.id}", fontSize = 20.sp, color = Color.White)

                    Spacer(Modifier.size(8.dp))

                    Box(Modifier.fillMaxWidth().height(1.dp).background(Color.White))
                }
            }
        }
    }
}

Profile Screen

The profile screen is the simplest screen of all. It just has a title and text positioned at the center. We will come back to the ProfileViewModel later.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen() {

    val viewModel: ProfileViewModel = koinViewModel()

    Scaffold(
        topBar = {
            CenterAlignedTopAppBar(
                title = { Text("Profile", color = Color.Black, fontSize = 30.sp) },
                navigationIcon = {
                    IconButton({ viewModel.navigateBack() }) {
                        Icon(
                            painterResource(R.drawable.back_arrow),
                            "back",
                            tint = Color.Black,
                            modifier = Modifier.size(30.dp)
                        )
                    }
                },
                modifier = Modifier,
                colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
            )
        }
    ) { paddingValues ->
        Box(Modifier.fillMaxSize().background(Color.White), Alignment.Center) {
            Text("Profile Screen", fontSize = 30.sp, color = Color.Black)
        }
    }
}

Item Details Screen

The ItemDetailsScreen will take the data from the ItemDetailsViewModel to display the number of the item and use the color for the background.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ItemDetailsScreen() {

    val viewModel: ItemDetailsViewModel = koinViewModel()

    Scaffold(
        topBar = {
            CenterAlignedTopAppBar(
                title = { Text("Item Details", color = Color.White, fontSize = 30.sp) },
                navigationIcon = {
                    IconButton({
                        // TODO: Add back navigation
                    }) {
                        Icon(
                            painterResource(R.drawable.back_arrow),
                            "back",
                            tint = Color.White,
                            modifier = Modifier.size(30.dp)
                        )
                    }
                },
                modifier = Modifier,
                colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
            )
        }
    ) { paddingValues ->
        val item by remember { mutableStateOf(viewModel.item) }

        Box(Modifier.fillMaxSize().background(Color(item.color)), Alignment.Center) {
            Text("Item #${item.id}", fontSize = 30.sp, color = Color.White)
        }
    }
}

The Domain layer

To replicate a real-life scenario, let’s utilize a service that will supply us with the necessary data to showcase on our various screens. The ItemService will be injected into both the AppViewModel and ItemDetailsViewModel. These viewModels will then retrieve the required data and send it to the view.

interface ItemService {
    val items: List<ItemData>
}

class RealItemService : ItemService {

    override val items: List<ItemData> = generateItems()

    // Generate a list of 100 items with the index as ID and a random color from the list below
    private fun generateItems(): List<ItemData> =
        (0 until 100).map { ItemData(id = it, color = getRandomColor()) }

    private fun getRandomColor(): Long {

        val colorList =
            listOf(
                0xFFF44336,
                0xFF009688,
                0xFF9C27B0,
                0xFF673AB7,
                0xFF3F51B5,
                0xFF4CAF50,
                0xFFFF5722,
                0xFF5F1700,
                0xFF690024,
                0xFF000D53,
                0xFF003602
            )

        return colorList[(0..colorList.lastIndex).random()]
    }
}

data class ItemData(val id: Int, val color: Long)

Dependency Injection

In order to be able to inject the service into the ViewModels and inject the ViewModels into the views, we are going to use Koin. The setup is very simple.

First, we need to create a custom App class:

class MainApp : Application() {

    override fun onCreate() {

        super.onCreate()

        startKoin {
            androidContext(this@MainApp)

            modules(appModule)
        }
    }
}

Set the MainApp as the class to run at launch in the manifest:

<manifest xmlns:android="<http://schemas.android.com/apk/res/android>" xmlns:tools="<http://schemas.android.com/tools>">

<application android:name=".MainApp">

Almost there! Just create a kotlin file called appModule.kt. This file will contain all the different components to inject:

val appModule = module {

    // create a viewModel with all the required dependencies
    viewModelOf(::ItemDetailsViewModel)

    viewModelOf(::ProfileViewModel)

    viewModelOf(::AppViewModel)

    // Inject a Singleton
    single<ItemService> { RealItemService() }
}

And now we can create our viewModels to inject in the views:


val viewModel: ViewModel = koinViewModel()

class AppViewModel(itemService: ItemService) : ViewModel() {
    val itemList = itemService.items
}

class ItemDetailsViewModel(itemService: ItemService) : ViewModel()

class ProfileViewModel(private val navigator: Navigator) : ViewModel()

Now that we’ve set up the foundation, it's time to explore how to manage navigation using Jetpack Compose. To achieve this, we'll utilize the NavHost Component to encapsulate the various destinations within our app:

// Call this view from the MainActivity
// setContent {
//     TutorialNavigationTheme {
//         AppScreen()
//     }
// }

@Composable
fun AppScreen() {

    val viewModel: AppViewModel = koinViewModel()

    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = HomeDestination.route) {
        composable(HomeDestination.route) {
            HomeContent(
                viewModel.itemList,
                {
                    // TODO: Add Profile Navigation
                }
            ) {
                // TODO: Add Item Navigation
            }
        }

        composableSlideInOut(ProfileDestination.route) { ProfileScreen() }

        composableSlideInOut(
            ItemDetailsDestination.route,
            arguments = ItemDetailsDestination.navArgs
        ) {
            ItemDetailsScreen()
        }
    }
}

composableSlideInOut is just an extension to customize the transition between two screens:

private const val transitionDuration = 700

fun NavGraphBuilder.composableSlideInOut(
    route: String,
    arguments: List<NamedNavArgument> = emptyList(),
    deepLinks: List<NavDeepLink> = emptyList(),
    enterTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
        {
            slideIntoContainer(
                AnimatedContentTransitionScope.SlideDirection.Left,
                tween(transitionDuration)
            )
        },
    exitTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
        {
            slideOutOfContainer(
                AnimatedContentTransitionScope.SlideDirection.Left,
                tween(transitionDuration)
            )
        },
    popEnterTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
        {
            slideIntoContainer(
                AnimatedContentTransitionScope.SlideDirection.Right,
                tween(transitionDuration)
            )
        },
    popExitTransition:
        (@JvmSuppressWildcards
        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
        {
            slideOutOfContainer(
                AnimatedContentTransitionScope.SlideDirection.Right,
                tween(transitionDuration)
            )
        },
    content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
) {

    composable(
        route,
        arguments,
        deepLinks,
        enterTransition,
        exitTransition,
        popEnterTransition,
        popExitTransition,
        content
    )
}

Within the NavHost, you'll find composables that specify the various views and how to navigate between them. Remember to include a NavController using rememberNavController to handle the navigation.

The NavHost must designate a start destination to determine the initial screen to show. This start destination is simply a String that corresponds to one provided to a composable.

Here, we want the app to start on the HomeScreen.

Destinations

There are numerous effective approaches to handle destinations - scattered Strings however, is not one of the recommended approaches. It's quite easy to misspell them and encounter a crash. Instead, a popular approach is to utilize either an enum class or a sealed class.

For now, we'll handle destinations differently. Every destination will be a class that contains a static string for the route.

object HomeDestination {

    private const val root = "home"

    const val route = root
}

Or:

object ProfileDestination {

    private const val root = "profile"

    const val route = root
}

This will work nicely for a simple navigation without passing any arguments between screens. It might even be overkill.

Issues may arise when trying to send data - like the item id - to ItemDetailsScreen in this case.

To send parameters to a different screen, you must include the parameter name after the destination name in the route definition, for example: "$root/$index". As you construct the route, the argument name will be substituted with its actual value.

To make it easy, each destination will extend NavDestination.

interface NavDestination {

    fun buildRoute(): String
}

object HomeDestination : NavDestination {

    override fun buildRoute(): String = route

    private const val root = "home"

    const val route = root
}

object ProfileDestination : NavDestination {

    override fun buildRoute(): String = route

    private const val root = "profile"

    const val route = root
}

class ItemDetailsDestination(val index: Int) : NavDestination {

    constructor(
        savedStateHandle: SavedStateHandle
    ) : this(index = requireNotNull(savedStateHandle.get<Int>(inputArg)))

    override fun buildRoute(): String = "$root/$index"

    companion object {

        private const val root = "item_details"

        private const val inputArg = "index"

        const val route = "$root/{$inputArg}"

        val navArgs = listOf(navArgument(inputArg) { type = NavType.IntType })
    }
}

As you can see, ItemDetailsDestination will pass the index of the selected item to the detailed screen. For this, we pass the static route and navigation arguments navArgs which consist of a list of name (inputArg which is index) and a type (Here NavType.IntType).

The custom constructor will be used to recreate ItemDetailsDestination with the params that were passed from HomeScreen thanks to SavedStateHandle. More to come on that.

Let's look at the main component of this architecture - the Navigator :

interface Navigator {

    fun setController(navController: NavController)

    fun navigate(route: NavDestination, builder: NavOptionsBuilder.() -> Unit = {})

    fun popBackStack()

    fun popBackStack(route: NavDestination, inclusive: Boolean, saveState: Boolean = false)
}

class RealNavigator : Navigator {

    private var navController: NavController? = null

    override fun setController(navController: NavController) {

        this.navController = navController
    }

    override fun navigate(route: NavDestination, builder: NavOptionsBuilder.() -> Unit) {

        navController?.navigate(route.buildRoute(), builder)
            ?: Log.w("Navigator", "No NavController set in the Navigator")
    }

    override fun popBackStack() {

        navController?.popBackStack() ?: Log.w("Navigator", "No NavController set in the Navigator")
    }

    override fun popBackStack(route: NavDestination, inclusive: Boolean, saveState: Boolean) {

        navController?.popBackStack(route.buildRoute(), inclusive, saveState)
    }
}

The Navigator will handle the navigation for us. From now on, every time we want to navigate, we are not going to call the usual navController.navigate("route") but navigator.navigate(NavDestination) instead. The Navigator will also take care of the back navigation calling popBackStack() to navigate back to the previous screen or to a desired screen present in the navigation back stack.

The Navigator will be injected into the viewModel of every screen and the view will call a viewModel function that will handle the desired navigation.

Now, let's see how to set that up.

In the appModule that we created previously, define how to instantiate the Navigator

val appModule = module {
    viewModelOf(::ItemDetailsViewModel)

    viewModelOf(::ProfileViewModel)

    viewModelOf(::AppViewModel)

    single<ItemService> { RealItemService() }

    // Add this line
    single<Navigator> { RealNavigator() }
}

In order for the Navigator to work, it needs to have the Compose NavController used by the NavHost. As the Navigator is a singleton, only one instance will be created, ensuring that we have a common Navigator across the app.

So we can inject it in the AppScreen where we will pass the NavController:

@Composable
fun AppScreen(
    // Inject the navigator
    navigator: Navigator = koinInject()
) {

    val viewModel: AppViewModel = koinViewModel()

    val navController = rememberNavController()

    // Pass the navController to the navigator
    LaunchedEffect(navController) { 	
    	navigator.setController(navController) 
    }
    
    ...
}

Now we can modify each viewModel by adding the navigator to their constructor. As the navigator is defined in the appModule, Koin knows how to instantiate and pass it to the ViewModel

class AppViewModel(itemService: ItemService, private val navigator: Navigator) : ViewModel() {

    val itemList = itemService.items

    fun navigateToProfile() {
        navigator.navigate(ProfileDestination)
    }

    fun navigateToItem(index: Int) {
        navigator.navigate(ItemDetailsDestination(index))
    }
}

The ViewModel will now handle the navigation. We can fill the HomeScreen lambda with the navigation logic:

fun AppScreen(navigator: Navigator = koinInject()) {
    ...
    NavHost(navController = navController, startDestination = HomeDestination.route) {
        composable(HomeDestination.route) {
            HomeScreen(viewModel.itemList, { viewModel.navigateToProfile() }) { item ->
                viewModel.navigateToItem(item.id)
            }
        }

        ...
    }
}

Receiving arguments

In order to receive the arguments, we will inject the Navigator and the SavedStateHandle which holds the arguments in a key-value format.

Koin takes care of SavedStateHandle as stated here.

class ItemDetailsViewModel(
    itemService: ItemService,
    private val navigator: Navigator,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val route = ItemDetailsDestination(savedStateHandle)

    val item = itemService.items[route.index]

    fun navigateBack() {
        navigator.popBackStack()
    }
}

First, ItemDetailsViewModel retrieves the index of the item from the SavedStateHandle and then gets the item object from the itemService.

We can now call navigateBack() from the view to navigate back to our HomeScreen

Scaffold(
    topBar = {
        CenterAlignedTopAppBar(
            title = { Text("Item Details", color = Color.White, fontSize = 30.sp) },
            navigationIcon = {
                IconButton({
                    // Call back navigation here
                    viewModel.navigateBack()
                }) {
                    Icon(
                        painterResource(R.drawable.back_arrow),
                        "back",
                        tint = Color.White,
                        modifier = Modifier.size(30.dp)
                    )
                }
            }
        )
    }
)

So there you have it! We've just explored the best way to handle navigation in your Jetpack Compose Android apps.

By using this architecture you can create a navigation structure that's not only robust but also super easy to maintain and scale, without using too many callbacks to call the NavController from the parent view with the NavHost.

This approach makes for cleaner code and reduces the chance of errors. If you're looking for help building your Android product, contact Studio today.

Subscribe to Studio Bytes, your weekly blast of all things tech.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Knowledge.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.