DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Compose Adaptive Layout & Bottom Navigation — Responsive Android UI

Compose Adaptive Layout & Bottom Navigation — Responsive Android UI

Building responsive Android apps that adapt seamlessly across phones, tablets, and foldables requires a strategic approach to navigation and layout. Jetpack Compose provides powerful tools to achieve this: WindowSizeClass for responsive breakpoints, and BottomNavigation components that intelligently shift based on screen size.

Part 1: Understanding Adaptive Layouts with WindowSizeClass

What is WindowSizeClass?

WindowSizeClass from androidx.compose.material3.windowsizeclass categorizes screen widths and heights into three dimensions:

// Compact: <600dp (phones in portrait)
// Medium: 600-840dp (tablets in portrait, foldables)
// Expanded: >840dp (tablets in landscape, large screens)

val windowSizeClass = calculateWindowSizeClass(activity = this)
val widthSizeClass = windowSizeClass.widthSizeClass
val heightSizeClass = windowSizeClass.heightSizeClass
Enter fullscreen mode Exit fullscreen mode

Responsive Layout Switching

Use when expressions to switch layouts based on screen size:

@Composable
fun AppContent(widthSizeClass: WindowWidthSizeClass) {
    when (widthSizeClass) {
        WindowWidthSizeClass.Compact -> CompactLayout()
        WindowWidthSizeClass.Medium -> MediumLayout()
        WindowWidthSizeClass.Expanded -> ExpandedLayout()
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Compact: Single-column layout, full-width content
  • Medium: Two-column layout (sidebar + content)
  • Expanded: Three-column layout (nav drawer + content + details)

Part 2: Navigation Strategies Across Screen Sizes

Android provides three primary navigation components; choose based on available width:

NavigationBar (BottomNavigation)

Used in: Compact screens (phones)

NavigationBar {
    NavigationBarItem(
        icon = { Icon(Icons.Outlined.Home, null) },
        label = { Text("Home") },
        selected = currentRoute == "home",
        onClick = { navController.navigate("home") }
    )
    NavigationBarItem(
        icon = { Icon(Icons.Outlined.Favorite, null) },
        label = { Text("Saved") },
        selected = currentRoute == "saved",
        onClick = { navController.navigate("saved") }
    )
}
Enter fullscreen mode Exit fullscreen mode

Characteristics:

  • Fixed at screen bottom
  • 2-5 items (compact display)
  • Ideal for 1-3 primary destinations
  • Takes minimal vertical space on phones

NavigationRail

Used in: Medium screens (tablets portrait)

NavigationRail(modifier = Modifier.width(80.dp)) {
    NavigationRailItem(
        icon = { Icon(Icons.Outlined.Home, null) },
        label = { Text("Home") },
        selected = currentRoute == "home",
        onClick = { navController.navigate("home") }
    )
    NavigationRailItem(
        icon = { Icon(Icons.Outlined.Favorite, null) },
        label = { Text("Saved") },
        selected = currentRoute == "saved",
        onClick = { navController.navigate("saved") }
    )
}
Enter fullscreen mode Exit fullscreen mode

Characteristics:

  • Fixed on left side
  • Vertical scrolling if >10 items
  • Takes minimal horizontal space
  • Good for tablets in portrait

NavigationDrawer

Used in: Expanded screens (tablets landscape, foldables)

PermanentNavigationDrawer(
    drawerContent = {
        PermanentDrawerSheet {
            Text("App Logo", modifier = Modifier.padding(16.dp))
            NavigationDrawerItem(
                label = { Text("Home") },
                selected = currentRoute == "home",
                onClick = { navController.navigate("home") }
            )
            NavigationDrawerItem(
                label = { Text("Saved") },
                selected = currentRoute == "saved",
                onClick = { navController.navigate("saved") }
            )
        }
    }
) {
    // Main content
}
Enter fullscreen mode Exit fullscreen mode

Characteristics:

  • Full-height sidebar (permanent or modal)
  • Supports labels + icons (more space)
  • Can display secondary menu items
  • Best UX for large screens

Part 3: List-Detail Pattern for Tablets

On compact screens, show list; on medium/expanded, show list + detail side-by-side:

@Composable
fun ListDetailContent(
    widthSizeClass: WindowWidthSizeClass,
    items: List<Item>,
    selectedId: String?,
    onSelect: (String) -> Unit
) {
    when (widthSizeClass) {
        WindowWidthSizeClass.Compact -> {
            // Single column: show list or detail based on selectedId
            if (selectedId == null) {
                ItemList(items, onSelect)
            } else {
                ItemDetail(selectedId, onBack = { onSelect("") })
            }
        }
        WindowWidthSizeClass.Medium, WindowWidthSizeClass.Expanded -> {
            // Two columns: always show list + detail
            Row {
                ItemList(
                    items,
                    onSelect,
                    modifier = Modifier.weight(0.4f)
                )
                if (selectedId != null) {
                    ItemDetail(
                        selectedId,
                        modifier = Modifier.weight(0.6f)
                    )
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 4: BottomNavigation Implementation & Backstack Management

Basic BottomNavigation with NavController

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    var currentRoute by remember { mutableStateOf("home") }

    Scaffold(
        bottomBar = {
            NavigationBar {
                val destinations = listOf("home", "saved", "profile")
                destinations.forEach { route ->
                    NavigationBarItem(
                        icon = { Icon(getIconForRoute(route), null) },
                        label = { Text(getLabelForRoute(route)) },
                        selected = currentRoute == route,
                        onClick = {
                            currentRoute = route
                            navController.navigate(route)
                        }
                    )
                }
            }
        }
    ) { innerPadding ->
        NavHost(navController, "home", Modifier.padding(innerPadding)) {
            composable("home") { HomeScreen() }
            composable("saved") { SavedScreen() }
            composable("profile") { ProfileScreen() }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced: Backstack Management with popUpTo, saveState, restoreState

Prevent backstack buildup when switching tabs (a common UX issue):

@Composable
fun SmartBottomNavigation(navController: NavHostController) {
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = navBackStackEntry?.destination?.route ?: "home"

    NavigationBar {
        NavigationBarItem(
            icon = { Icon(Icons.Outlined.Home, null) },
            label = { Text("Home") },
            selected = currentRoute == "home",
            onClick = {
                navController.navigate("home") {
                    // Pop everything back to start destination
                    popUpTo(navController.graph.findStartDestination().id) {
                        saveState = true
                    }
                    // Avoid multiple copies of same destination
                    lazyRestoreState = true
                    // Don't create new instance; restore saved state
                    restoreState = true
                }
            }
        )
        NavigationBarItem(
            icon = { Icon(Icons.Outlined.Favorite, null) },
            label = { Text("Saved") },
            selected = currentRoute == "saved",
            onClick = {
                navController.navigate("saved") {
                    popUpTo(navController.graph.findStartDestination().id) {
                        saveState = true
                    }
                    lazyRestoreState = true
                    restoreState = true
                }
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Key parameters:

  • popUpTo(): Pop backstack up to (and including) a destination
  • saveState = true: Save the state of all destinations in backstack
  • lazyRestoreState = true: Only create destination if needed
  • restoreState = true: Restore saved UI state when returning

BadgedBox: Show Notification Badges on NavigationBarItems

@Composable
fun NotificationAwareBottomNav(
    unreadCount: Int = 0,
    savedCount: Int = 0
) {
    NavigationBar {
        NavigationBarItem(
            icon = { Icon(Icons.Outlined.Home, null) },
            label = { Text("Home") },
            selected = true,
            onClick = {}
        )

        NavigationBarItem(
            icon = {
                BadgedBox(badge = {
                    if (unreadCount > 0) {
                        Badge { Text(unreadCount.toString()) }
                    }
                }) {
                    Icon(Icons.Outlined.Notifications, null)
                }
            },
            label = { Text("Notifications") },
            selected = false,
            onClick = {}
        )

        NavigationBarItem(
            icon = {
                BadgedBox(badge = {
                    if (savedCount > 0) {
                        Badge(
                            backgroundColor = Color.Red,
                            contentColor = Color.White
                        ) {
                            Text(savedCount.toString())
                        }
                    }
                }) {
                    Icon(Icons.Outlined.Favorite, null)
                }
            },
            label = { Text("Saved") },
            selected = false,
            onClick = {}
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Nested Navigation in Bottom Tabs

Each bottom tab can have its own nested navigation graph:

@Composable
fun BottomNavWithNestedGraphs(navController: NavHostController) {
    Scaffold(
        bottomBar = {
            NavigationBar {
                val currentRoute = navController.currentDestination?.route

                NavigationBarItem(
                    icon = { Icon(Icons.Outlined.Home, null) },
                    selected = currentRoute?.startsWith("home") == true,
                    onClick = { navController.navigate("home_graph") }
                )

                NavigationBarItem(
                    icon = { Icon(Icons.Outlined.Favorite, null) },
                    selected = currentRoute?.startsWith("saved") == true,
                    onClick = { navController.navigate("saved_graph") }
                )
            }
        }
    ) { innerPadding ->
        NavHost(navController, "home_graph", Modifier.padding(innerPadding)) {
            // Home graph with its own backstack
            navigation(startDestination = "home", route = "home_graph") {
                composable("home") { HomeScreen() }
                composable("home_detail/{id}") { HomeDetailScreen() }
            }

            // Saved graph with its own backstack
            navigation(startDestination = "saved", route = "saved_graph") {
                composable("saved") { SavedScreen() }
                composable("saved_detail/{id}") { SavedDetailScreen() }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Show/Hide BottomNav on Detail Screens

Hide BottomNavigation when viewing detail content (common pattern):

@Composable
fun AdaptiveApp(navController: NavHostController) {
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = navBackStackEntry?.destination?.route ?: ""

    // Hide bottom nav on detail screens
    val showBottomNav = !currentRoute.contains("detail")

    Scaffold(
        bottomBar = {
            if (showBottomNav) {
                NavigationBar {
                    // Navigation items
                }
            }
        }
    ) { innerPadding ->
        NavHost(navController, "home", Modifier.padding(innerPadding)) {
            composable("home") { HomeScreen() }
            composable("saved") { SavedScreen() }
            composable("home_detail/{id}") { HomeDetailScreen() }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 5: Complete Example — Adaptive App with BottomNav

@Composable
fun AdaptiveShoppingApp() {
    val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity)
    val navController = rememberNavController()

    when (windowSizeClass.widthSizeClass) {
        WindowWidthSizeClass.Compact -> {
            // Phone: BottomNavigation + single column
            Scaffold(
                bottomBar = { CompactBottomNav(navController) }
            ) { innerPadding ->
                CompactNavGraph(navController, Modifier.padding(innerPadding))
            }
        }
        WindowWidthSizeClass.Medium -> {
            // Tablet portrait: NavigationRail + two columns
            Row {
                MediumNavigationRail(navController, Modifier.width(80.dp))
                MediumNavGraph(navController, Modifier.weight(1f))
            }
        }
        WindowWidthSizeClass.Expanded -> {
            // Tablet landscape: Permanent Drawer + List-Detail
            Row {
                PermanentDrawer(navController, Modifier.width(240.dp))
                ExpandedListDetailContent(navController, Modifier.weight(1f))
            }
        }
    }
}

@Composable
fun CompactBottomNav(navController: NavHostController) {
    NavigationBar {
        val currentRoute by rememberUpdatedState(
            navController.currentDestination?.route ?: "home"
        )

        listOf(
            "home" to Icons.Outlined.Home,
            "search" to Icons.Outlined.Search,
            "cart" to Icons.Outlined.ShoppingCart,
            "account" to Icons.Outlined.Person
        ).forEach { (route, icon) ->
            NavigationBarItem(
                icon = { Icon(icon, null) },
                label = { Text(route.capitalize()) },
                selected = currentRoute == route,
                onClick = {
                    navController.navigate(route) {
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        lazyRestoreState = true
                        restoreState = true
                    }
                }
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Use lazyRestoreState to avoid recreating UI state on tab switches
  2. Implement popUpTo correctly to prevent backstack explosion when cycling through tabs
  3. Hide navigation on detail screens for immersive viewing on phones
  4. Show 3-5 primary destinations in BottomNav; use secondary menus for more
  5. Test on multiple screen sizes — use Device Configuration in Android Studio
  6. Use BadgedBox for notifications and unread counts
  7. Implement nested graphs per tab to maintain independent backstacks

Summary

Combine WindowSizeClass with intelligent navigation selection:

  • Compact (phones): NavigationBar + BottomNavigation + hide on detail
  • Medium (tablets): NavigationRail + two-column layout
  • Expanded (large tablets): PermanentNavigationDrawer + list-detail pattern

Master backstack management (popUpTo, saveState, restoreState) to deliver a polished user experience where switching tabs feels instantaneous and respects navigation history.


8 Android App Templates → https://myougatheax.gumroad.com

Top comments (0)