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
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()
}
}
- 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") }
)
}
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") }
)
}
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
}
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)
)
}
}
}
}
}
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() }
}
}
}
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
}
}
)
}
}
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 = {}
)
}
}
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() }
}
}
}
}
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() }
}
}
}
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
}
}
)
}
}
}
Best Practices
-
Use
lazyRestoreStateto avoid recreating UI state on tab switches -
Implement
popUpTocorrectly to prevent backstack explosion when cycling through tabs - Hide navigation on detail screens for immersive viewing on phones
- Show 3-5 primary destinations in BottomNav; use secondary menus for more
- Test on multiple screen sizes — use Device Configuration in Android Studio
-
Use
BadgedBoxfor notifications and unread counts - 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)