In this article, I'll guide you through integrating a Bottom Navigation Bar for compact screens and a Navigation Rail for larger screens in a Compose Multiplatform app. By the end, you'll have a fully functional app that adapts to various screen sizes, and can be deployed on Android, iOS, macOS, Linux, and Windows.

Step 1: Add Dependencies to libs.versions.toml

First, add the necessary dependencies for navigation and window size classes in your libs.versions.toml file. This file defines the versions and library modules you'll use in your project:

[versions]
# Define the versions of the dependencies
navigationCompose = "2.8.0-alpha02"
size = "0.5.0"

[libraries]
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
screen-size = { module = "dev.chrisbanes.material3:material3-window-size-class-multiplatform", version.ref = "size" }

Next, include these dependencies in the build.gradle.kts file of your Compose Multiplatform project to make them available across platforms:

// build.gradle.kts (Module :composeApp)
kotlin {
    sourceSets {
        val commonMain by getting {
            dependencies {
                // Material 3
                implementation(compose.material3)
                
                // Navigation
                implementation(libs.navigation.compose)

                // window-size
                implementation(libs.screen.size)
            }
        }
    }
}

Step 2: Create a NavigationItem Data Class

To manage the items for both the bottom navigation and the navigation rail, we need a data class that defines the properties for each navigation item. Here's how you can create a NavigationItem data class that works across different platforms in Compose:

import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource

data class NavigationItem(
    val unSelectedIcon: ImageVector /* or  DrawableResource*/,
    val selectedIcon: ImageVector /* or  DrawableResource*/,
    val title: String /* or  StringResource  */,
    val route: String
)

In this step, we:

  • create a NavigationItem data class.

This data class allows you to define the icons, titles, and routes for each item in both the bottom navigation bar and navigation rail. You can use ImageVector or DrawableResource for the icons, and String or StringResource for the titles, making it flexible for different resource types in Compose Multiplatform.

Step 3: Define Navigation Routes and Items

Next, you'll want to define the navigation routes for your app and list the navigation items. Here's how to set up a simple route system using sealed class and a list of NavigationItem objects:

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*

object Graph {
    const val NAVIGATION_BAR_SCREEN_GRAPH = "navigationBarScreenGraph"
}

sealed class Routes(var route: String) {
    data object Home : Routes("home")
    data object Setting : Routes("setting")
    data object HomeDetail : Routes("homeDetail")
    data object SettingDetail : Routes("settingDetail")
}

val navigationItemsLists = listOf(
    NavigationItem(
        unSelectedIcon = Icons.Outlined.Home,
        selectedIcon = Icons.Filled.Home,
        title = "Home",
        route = Routes.Home.route,
    ),
    NavigationItem(
        unSelectedIcon = Icons.Outlined.Search,
        selectedIcon = Icons.Filled.Search,
        title = "Setting",
        route = Routes.Setting.route,
    ),
)

In this step, we:

  • Defined the main routes in the app using a sealed class Routes.
  • Created a list of NavigationItem objects, specifying the icons, titles, and routes for each item. These items will be used in both the bottom navigation bar and the navigation rail to handle navigation based on screen size.

Step 4: Implement the Bottom Navigation Bar and Navigation Rail

To handle navigation across different screen sizes, we'll implement both a Bottom Navigation Bar (for smaller screens) and a Navigation Rail (for larger screens). These components will dynamically display navigation items based on the current route.

Here's how you can set up the BottomNavigationBar and NavigationSideBar composables:

import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp

@Composable
fun BottomNavigationBar(
    items: List<NavigationItem>,
    currentRoute: String?,
    onItemClick: (NavigationItem) -> Unit
) {
    NavigationBar(
        modifier = Modifier.fillMaxWidth(),
    ) {
        items.forEach { navigationItem ->
            NavigationBarItem(
                selected = currentRoute == navigationItem.route,
                onClick = { onItemClick(navigationItem) },
                icon = {
                    Icon(
                        imageVector = if (navigationItem.route == currentRoute) navigationItem.selectedIcon else navigationItem.unSelectedIcon,
                        contentDescription = navigationItem.title,
                    )
                },
                label = {
                    Text(
                        text = navigationItem.title,
                        style = if (navigationItem.route == currentRoute) MaterialTheme.typography.labelLarge
                        else MaterialTheme.typography.labelMedium,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis
                    )
                },
            )
        }
    }
}

@Composable
fun NavigationSideBar(
    items: List<NavigationItem>,
    currentRoute: String?,
    onItemClick: (NavigationItem) -> Unit
) {
    NavigationRail(
        modifier = Modifier.fillMaxHeight(),
        containerColor = MaterialTheme.colorScheme.surface,
    ) {
        items.forEach { navigationItem ->
            NavigationRailItem(
                selected = navigationItem.route == currentRoute,
                onClick = { onItemClick(navigationItem) },
                icon = {
                    Icon(
                        imageVector = if (navigationItem.route == currentRoute) navigationItem.selectedIcon else navigationItem.unSelectedIcon,
                        contentDescription = navigationItem.title,
                    )
                },
                modifier = Modifier.padding(vertical = 12.dp),
                label = {
                    Text(
                        text = navigationItem.title,
                        style = if (navigationItem.route == currentRoute) MaterialTheme.typography.labelLarge
                        else MaterialTheme.typography.labelMedium,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis
                    )
                },
            )
        }
    }
}

In this step, we:

  • Implemented the BottomNavigationBar composable for small screens. It uses a NavigationBar to display the items and highlights the selected one based on the currentRoute.
  • Created the NavigationSideBar composable for large screens. It uses a NavigationRail and similarly highlights the selected item.

Both components are designed to switch between the unselected and selected icons, update the text style based on the route, and handle clicks with the onItemClick callback.

Step 5: Implement Home and Home Detail Screens

To create the screens that will be displayed when navigating between the home and its detail, we define HomeScreen and HomeDetailScreen. These screens handle user input and navigation between each other.

import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.meet.bottom_navigation_bar_navigation_rail.navigation.Routes

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
    rootNavController: NavController, paddingValues: PaddingValues
) {
    var name by remember { mutableStateOf("") }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues),
        verticalArrangement = Arrangement.SpaceBetween,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        TopAppBar(
            title = {
                Text(
                    text = "Home",
                    style = MaterialTheme.typography.headlineLarge,
                    fontWeight = FontWeight.Bold,
                    color = MaterialTheme.colorScheme.onBackground
                )
            }
        )
        
        Spacer(modifier = Modifier.height(20.dp))
        
        TextField(
            value = name,
            onValueChange = { name = it },
            label = { Text(text = "Enter the name") }
        )
        
        Button(onClick = {
            rootNavController.currentBackStackEntry?.savedStateHandle?.apply {
                set("name", name)
            }
            rootNavController.navigate(Routes.HomeDetail.route)
        }) {
            Text(
                text = "Move to Home Detail Screen",
                fontSize = 20.sp
            )
        }
        
        Spacer(modifier = Modifier.height(20.dp))
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeDetailScreen(
    rootNavController: NavController,
    name: String
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceBetween,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        TopAppBar(
            title = {
                Text(
                    text = "Home Detail",
                    style = MaterialTheme.typography.headlineLarge,
                    fontWeight = FontWeight.Bold,
                    color = MaterialTheme.colorScheme.onBackground
                )
            },
            navigationIcon = {
                IconButton(onClick = { rootNavController.navigateUp() }) {
                    Icon(
                        imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                        contentDescription = null,
                    )
                }
            }
        )
        
        Text(
            text = "Name = $name",
            fontSize = 20.sp
        )
        
        Spacer(modifier = Modifier.height(20.dp))
    }
}

In this step:

  • HomeScreen allows users to input a name and navigate to HomeDetailScreen, passing the entered name using savedStateHandle.
  • HomeDetailScreen displays the passed name and provides a back button to return to HomeScreen.

Step 6: Implement Setting and Setting Detail Screens

Now, let's create the SettingScreen and SettingDetailScreen to manage settings in your app and allow navigation between these screens.

import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.meet.bottom_navigation_bar_navigation_rail.navigation.Routes

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingScreen(
    rootNavController: NavController,
    paddingValues: PaddingValues
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues),
        verticalArrangement = Arrangement.SpaceBetween,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        TopAppBar(
            title = {
                Text(
                    text = "Setting",
                    style = MaterialTheme.typography.headlineLarge,
                    fontWeight = FontWeight.Bold,
                    color = MaterialTheme.colorScheme.onBackground
                )
            }
        )
        
        Spacer(modifier = Modifier.height(20.dp))
        
        Button(onClick = {
            rootNavController.navigate(Routes.SettingDetail.route)
        }) {
            Text(
                text = "Move to Setting Detail Screen",
                fontSize = 20.sp
            )
        }
        
        Spacer(modifier = Modifier.height(20.dp))
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingDetailScreen(
    rootNavController: NavController,
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceBetween,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        TopAppBar(
            title = {
                Text(
                    text = "Setting Detail",
                    style = MaterialTheme.typography.headlineLarge,
                    fontWeight = FontWeight.Bold,
                    color = MaterialTheme.colorScheme.onBackground
                )
            },
            navigationIcon = {
                IconButton(onClick = { rootNavController.navigateUp() }) {
                    Icon(
                        imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                        contentDescription = null,
                    )
                }
            }
        )
    }
}

In this step:

  • SettingScreen presents a button to navigate to SettingDetailScreen.
  • SettingDetailScreen provides a back button to return to the SettingScreen.

Step 7: Set Up the Navigation Graph

To handle screen navigation in your Compose Multiplatform app, set up the navigation graph. This will include both the main screens and detail screens. The RootNavGraph function will define the main structure for navigation, while the mainNavGraph function will handle the individual navigation routes.

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import com.meet.bottom_navigation_bar_navigation_rail.navigation.Graph
import com.meet.bottom_navigation_bar_navigation_rail.navigation.Routes
import com.meet.bottom_navigation_bar_navigation_rail.screens.HomeDetailScreen
import com.meet.bottom_navigation_bar_navigation_rail.screens.HomeScreen
import com.meet.bottom_navigation_bar_navigation_rail.screens.SettingDetailScreen
import com.meet.bottom_navigation_bar_navigation_rail.screens.SettingScreen

@Composable
fun RootNavGraph(
    rootNavController: NavHostController,
    innerPadding: PaddingValues
) {
    NavHost(
        navController = rootNavController,
        startDestination = Graph.NAVIGATION_BAR_SCREEN_GRAPH,
    ) {
        mainNavGraph(rootNavController = rootNavController, innerPadding = innerPadding)
        composable(
            route = Routes.HomeDetail.route,
        ) {
            rootNavController.previousBackStackEntry?.savedStateHandle?.get<String>("name")?.let { name ->
                HomeDetailScreen(rootNavController = rootNavController, name = name)
            }
        }
        composable(
            route = Routes.SettingDetail.route,
        ) {
            SettingDetailScreen(rootNavController = rootNavController)
        }
    }
}
fun NavGraphBuilder.mainNavGraph(
    rootNavController: NavHostController,
    innerPadding: PaddingValues
) {
    navigation(
        startDestination = Routes.Home.route,
        route = Graph.NAVIGATION_BAR_SCREEN_GRAPH
    ) {
        composable(route = Routes.Home.route) {
            HomeScreen(rootNavController = rootNavController, paddingValues = innerPadding)
        }
        composable(route = Routes.Setting.route) {
            SettingScreen(rootNavController = rootNavController, paddingValues = innerPadding)
        }
    }
}

In this step:

  • We define a RootNavGraph that starts the navigation graph and handles both main and detail screens.
  • mainNavGraph manages the navigation for the HomeScreen and SettingScreen.
  • We use composable to declare each screen's route, and for the detail screens (HomeDetailScreen, SettingDetailScreen), we retrieve parameters using savedStateHandle when navigating between them.

Step 8: Building the Adaptive UI for Different Screen Sizes

In this step, we'll implement the MainScreen composable, which handles displaying either the Bottom Navigation Bar or the Navigation Rail based on the screen size.

import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.*
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.meet.bottom_navigation_bar_navigation_rail.navigation.*

@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun MainScreen() {
    val windowSizeClass = calculateWindowSizeClass()
    val isMediumExpandedWWSC by remember(windowSizeClass) {
        derivedStateOf {
            windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact
        }
    }
    val rootNavController = rememberNavController()
    val navBackStackEntry by rootNavController.currentBackStackEntryAsState()
    val currentRoute by remember(navBackStackEntry) {
        derivedStateOf {
            navBackStackEntry?.destination?.route
        }
    }
    val navigationItem by remember {
        derivedStateOf {
            navigationItemsLists.find { it.route == currentRoute }
        }
    }
    val isMainScreenVisible by remember(isMediumExpandedWWSC) {
        derivedStateOf {
            navigationItem != null
        }
    }
    val isBottomBarVisible by remember(isMediumExpandedWWSC) {
        derivedStateOf {
            if (!isMediumExpandedWWSC) {
                navigationItem != null
            } else {
                false
            }
        }
    }
    MainScaffold(
        rootNavController = rootNavController,
        currentRoute = currentRoute,
        isMediumExpandedWWSC = isMediumExpandedWWSC,
        isBottomBarVisible = isBottomBarVisible,
        isMainScreenVisible = isMainScreenVisible,
        onItemClick = { currentNavigationItem ->
            rootNavController.navigate(currentNavigationItem.route) {
                popUpTo(rootNavController.graph.startDestinationRoute ?: "") {
                    saveState = true
                }
                launchSingleTop = true
                restoreState = true
            }
        }
    )
}

@Composable
fun MainScaffold(
    rootNavController: NavHostController,
    currentRoute: String?,
    isMediumExpandedWWSC: Boolean,
    isBottomBarVisible: Boolean,
    isMainScreenVisible: Boolean,
    onItemClick: (NavigationItem) -> Unit,
) {
    Row {
        AnimatedVisibility(
            modifier = Modifier.background(MaterialTheme.colorScheme.surface),
            visible = isMediumExpandedWWSC && isMainScreenVisible,
            enter = slideInHorizontally(
                // Slide in from the left
                initialOffsetX = { fullWidth -> -fullWidth }
            ),
            exit = slideOutHorizontally(
                // Slide out to the right
                targetOffsetX = { fullWidth -> -fullWidth }
            )
        ) {
            NavigationSideBar(
                items = navigationItemsLists,
                currentRoute = currentRoute,
                onItemClick = { currentNavigationItem ->
                    onItemClick(currentNavigationItem)
                }
            )
        }
        Scaffold(
            bottomBar = {
                AnimatedVisibility(
                    visible = isBottomBarVisible,
                    enter = slideInVertically(
                        // Slide in from the bottom
                        initialOffsetY = { fullHeight -> fullHeight }
                    ),
                    exit = slideOutVertically(
                        // Slide out to the bottom
                        targetOffsetY = { fullHeight -> fullHeight }
                    )
                ) {
                    BottomNavigationBar(
                        items = navigationItemsLists,
                        currentRoute = currentRoute,
                        onItemClick = { currentNavigationItem ->
                            onItemClick(currentNavigationItem)
                        }
                    )
                }
            }
        ) { innerPadding ->
            RootNavGraph(
                rootNavController = rootNavController,
                innerPadding = innerPadding,
            )
        }
    }
}

Here, we use WindowSizeClass to dynamically adapt the UI based on the width of the device screen. If the width is compact, we show the Bottom Navigation Bar; if it's larger (like on tablets or desktops), we switch to a Navigation Rail for better usability.

Here's a breakdown of how it works:

  • WindowSizeClass: Helps detect the screen size. We use it to differentiate between compact and expanded layouts.
  • AnimatedVisibility: This Jetpack Compose API is used to animate the transition between the Bottom Navigation Bar and the Navigation Rail. For example, when switching to a larger screen size, the Navigation Rail slides in from the left, giving a polished, responsive look.
  • Scaffold: Acts as the container for the UI layout, managing the positioning of the Bottom Navigation Bar and the Navigation Rail, along with handling content padding.

By doing this, you create a flexible UI that works for both smaller devices like phones, where space is limited, and larger screens like tablets and desktops, where more space allows for a side navigation rail.

Step 9: Create the Main Composable Function

In this step, we'll define the App composable in the common module. This function will act as the entry point of the application, applying a MaterialTheme to the MainScreen.

import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import org.jetbrains.compose.ui.tooling.preview.Preview

@Composable
@Preview
fun App() {
    MaterialTheme {
        MainScreen()
    }
}

In this step:

  • We define the App composable, which wraps MainScreen in a MaterialTheme, ensuring a consistent look and feel across the app.
  • A @Preview annotation is used for quickly visualizing the App composable during development.

Step 10: Set Up the Desktop Module

Now let's set up the main function in the desktop module to launch the app. This function defines the application window and the entry point of the desktop app.

import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import java.awt.Dimension

fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "Bottom-Navigation-Bar-Navigation-Rail",
    ) {
        window.minimumSize = Dimension(640, 480)
        App()
    }
}

In this step:

  • We use Window from androidx.compose.ui.window to create the desktop app's main window.
  • App() is called to display the UI within the window.
  • The minimum window size is set to 640x480 to ensure proper display on smaller screens.

Conclusion

You've successfully integrated Bottom Navigation Bar for compact screens and a Navigation Rail for larger screens in a Compose Multiplatform. Now, you can run the app on each platform — Android, iOS, macOS, Linux, and Windows — to ensure everything works smoothly.

Example Github Repo:

If you're interested in learning more about Kotlin Multiplatform and Compose Multiplatform, check out my playlist on YouTube Channel: Mastering Kotlin Multiplatform with Jetpack Compose: Complete Guide in Hindi

Thank you for reading! 🙌🙏✌ I hope you found this guide useful.

Don't forget to clap 👏 to support me and follow for more insightful articles about Android Development, Kotlin, and KMP. If you need any help related to Android, Kotlin, and KMP, I'm always happy to assist.

Explore More Projects

If you're interested in seeing full applications built with Kotlin Multiplatform and Jetpack Compose, check out these open-source projects:

  • News Kotlin Multiplatform App (Supports Android, iOS, Windows, macOS, Linux): News KMP App is a Kotlin Compose Multiplatform (KMP) project that aims to provide a consistent news reading experience across multiple platforms, including Android, iOS, Windows, macOS, and Linux. This project leverages Kotlin's multiplatform capabilities to share code and logic while using Compose for UI, ensuring a seamless and native experience on each platform. GitHub Repository: News-KMP-App
  • Gemini AI Kotlin Multiplatform App (Supports Android, iOS, Windows, macOS, Linux, and Web): Gemini AI KMP App is a Kotlin Compose Multiplatform project designed by Gemini AI where you can retrieve information from text and images in a conversational format. Additionally, it allows storing chats group-wise using SQLDelight and KStore, and facilitates changing the Gemini API key. GitHub Repository: Gemini-AI-KMP-App

Follow me on

Medium , YouTube , GitHub , Instagram , LinkedIn , Buy Me a Coffee , Twitter