Your app looks great on a phone. But open it on a tablet and there is a giant empty space. Open it on a foldable and the layout breaks.
In 2026, adaptive layouts are not optional. Google Play requires apps to support different screen sizes. And with foldables, tablets, and ChromeOS — your app will run on screens from 4 inches to 15 inches.
This tutorial teaches you how to build one codebase that adapts to every screen.
What Are Adaptive Layouts?
An adaptive layout changes its structure based on the available screen space:
Phone (compact): Tablet (expanded):
┌──────────┐ ┌──────────┬──────────┐
│ │ │ │ │
│ List │ │ List │ Detail │
│ │ │ │ │
│ │ │ │ │
└──────────┘ └──────────┴──────────┘
Tap item → navigate Tap item → shows beside
to detail screen the list (side by side)
On a phone, you show one screen at a time. On a tablet, you show two panes side by side. Same app, different layout.
Window Size Classes
Instead of checking exact pixel widths, use window size classes — categories that group screen sizes:
| Width Class | Size | Examples |
|---|---|---|
| Compact | < 600dp | Phones in portrait |
| Medium | 600-840dp | Phones in landscape, small tablets |
| Expanded | > 840dp | Tablets, foldables unfolded, desktop |
Getting the Current Window Size Class
// Add dependency
implementation("androidx.compose.material3.adaptive:adaptive:1.1.0")
// In your Composable
@Composable
fun AdaptiveApp() {
val windowInfo = currentWindowAdaptiveInfo()
val widthClass = windowInfo.windowSizeClass.windowWidthSizeClass
when (widthClass) {
WindowWidthSizeClass.COMPACT -> {
// Phone layout — single column
PhoneLayout()
}
WindowWidthSizeClass.MEDIUM -> {
// Small tablet — can show more
MediumLayout()
}
WindowWidthSizeClass.EXPANDED -> {
// Large screen — two panes
TabletLayout()
}
}
}
Important: Don’t use window size classes for isTablet checks. A phone in landscape mode can be “Medium”. A foldable can switch between “Compact” and “Expanded” while your app is running. Design for the available space, not the device type.
Example 1: Responsive Grid
A common pattern — show 1, 2, or 3 columns depending on screen width:
@Composable
fun ResponsiveGrid(items: List<String>) {
val windowInfo = currentWindowAdaptiveInfo()
val columns = when (windowInfo.windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT -> 1
WindowWidthSizeClass.MEDIUM -> 2
else -> 3
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(items) { item ->
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = item,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium
)
}
}
}
}
On a phone: 1 column. On a small tablet: 2 columns. On a large tablet: 3 columns. All automatic.
Example 2: Adaptive Content Layout
Different layouts for compact vs expanded — without duplicating the content:
@Composable
fun AdaptiveArticleScreen(article: Article) {
val windowInfo = currentWindowAdaptiveInfo()
val isExpanded = windowInfo.windowSizeClass.windowWidthSizeClass ==
WindowWidthSizeClass.EXPANDED
if (isExpanded) {
// Tablet: image on left, text on right
Row(modifier = Modifier.fillMaxSize().padding(24.dp)) {
ArticleImage(
imageUrl = article.imageUrl,
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
.clip(RoundedCornerShape(16.dp))
)
Spacer(modifier = Modifier.width(24.dp))
ArticleContent(
article = article,
modifier = Modifier.weight(0.6f)
)
}
} else {
// Phone: image on top, text below
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
ArticleImage(
imageUrl = article.imageUrl,
modifier = Modifier
.fillMaxWidth()
.height(250.dp)
)
ArticleContent(
article = article,
modifier = Modifier.padding(16.dp)
)
}
}
}
// Reusable components — same content, different arrangement
@Composable
fun ArticleImage(imageUrl: String, modifier: Modifier = Modifier) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.Crop
)
}
@Composable
fun ArticleContent(article: Article, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(article.title, style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(article.author, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(16.dp))
Text(article.body, style = MaterialTheme.typography.bodyLarge)
}
}
The key pattern: extract content into reusable components (ArticleImage, ArticleContent), then arrange them differently based on screen size.
Example 3: List-Detail Pattern
The most common adaptive pattern — a list on the left, detail on the right:
Using ListDetailPaneScaffold (Recommended)
// Add dependency
implementation("androidx.compose.material3.adaptive:adaptive-layout:1.1.0")
implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.1.0")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveNotesList(notes: List<Note>) {
var selectedNote by remember { mutableStateOf<Note?>(null) }
val navigator = rememberListDetailPaneScaffoldNavigator<Note>()
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
// List pane — always visible
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(notes, key = { it.id }) { note ->
NoteListItem(
note = note,
isSelected = note == selectedNote,
onClick = {
selectedNote = note
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, note)
}
)
}
}
},
detailPane = {
// Detail pane — visible on expanded screens, navigated to on compact
selectedNote?.let { note ->
NoteDetailContent(note = note)
} ?: Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"Select a note",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
)
}
@Composable
fun NoteListItem(note: Note, isSelected: Boolean, onClick: () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
shape = RoundedCornerShape(12.dp),
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(note.title, fontWeight = FontWeight.Bold)
Text(
note.content,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
fun NoteDetailContent(note: Note) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp)
) {
Text(note.title, style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
Text(note.content, style = MaterialTheme.typography.bodyLarge)
}
}
ListDetailPaneScaffold handles everything automatically:
- On phone (compact): shows list, then navigates to detail on tap
- On tablet (expanded): shows list and detail side by side
- Back navigation: handled automatically
- Foldable: adapts when folding/unfolding
Manual List-Detail (Without Library)
If you prefer more control:
@Composable
fun ManualListDetail(notes: List<Note>) {
val windowInfo = currentWindowAdaptiveInfo()
val isExpanded = windowInfo.windowSizeClass.windowWidthSizeClass ==
WindowWidthSizeClass.EXPANDED
var selectedNote by remember { mutableStateOf<Note?>(null) }
if (isExpanded) {
// Tablet: side by side
Row(modifier = Modifier.fillMaxSize()) {
// List — 40% width
LazyColumn(
modifier = Modifier.weight(0.4f).fillMaxHeight(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(notes, key = { it.id }) { note ->
NoteListItem(
note = note,
isSelected = note == selectedNote,
onClick = { selectedNote = note }
)
}
}
// Divider
VerticalDivider()
// Detail — 60% width
Box(modifier = Modifier.weight(0.6f).fillMaxHeight()) {
selectedNote?.let { NoteDetailContent(it) }
?: Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Select a note")
}
}
}
} else {
// Phone: one at a time
if (selectedNote != null) {
Column {
TextButton(onClick = { selectedNote = null }) {
Text("← Back to list")
}
NoteDetailContent(selectedNote!!)
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(notes, key = { it.id }) { note ->
NoteListItem(
note = note,
isSelected = false,
onClick = { selectedNote = note }
)
}
}
}
}
}
Example 4: Adaptive Navigation
NavigationSuiteScaffold automatically switches between:
- Bottom navigation bar (compact — phones)
- Navigation rail (medium — tablets in portrait)
- Navigation drawer (expanded — tablets in landscape, desktop)
// Add dependency
implementation("androidx.compose.material3:material3-adaptive-navigation-suite")
enum class AppDestination(
val label: String,
val icon: ImageVector
) {
Home("Home", Icons.Default.Home),
Search("Search", Icons.Default.Search),
Profile("Profile", Icons.Default.Person),
Settings("Settings", Icons.Default.Settings)
}
@Composable
fun AdaptiveApp() {
var currentDestination by remember { mutableStateOf(AppDestination.Home) }
NavigationSuiteScaffold(
navigationSuiteItems = {
AppDestination.entries.forEach { destination ->
item(
selected = destination == currentDestination,
onClick = { currentDestination = destination },
icon = {
Icon(destination.icon, contentDescription = destination.label)
},
label = { Text(destination.label) }
)
}
}
) {
// Content area — changes based on selected destination
when (currentDestination) {
AppDestination.Home -> HomeScreen()
AppDestination.Search -> SearchScreen()
AppDestination.Profile -> ProfileScreen()
AppDestination.Settings -> SettingsScreen()
}
}
}
On a phone: bottom navigation bar. On a tablet: navigation rail on the side. On a large screen: full navigation drawer. Zero extra code — NavigationSuiteScaffold handles it.
Example 5: Responsive Padding and Sizing
Different padding and sizing for different screens:
@Composable
fun ResponsiveCard(title: String, content: String) {
val windowInfo = currentWindowAdaptiveInfo()
val horizontalPadding = when (windowInfo.windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT -> 16.dp
WindowWidthSizeClass.MEDIUM -> 32.dp
else -> 64.dp // More margin on large screens
}
val maxWidth = when (windowInfo.windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT -> Dp.Infinity // Full width
WindowWidthSizeClass.MEDIUM -> 600.dp
else -> 800.dp // Cap width on large screens
}
Box(
modifier = Modifier.fillMaxWidth().padding(horizontal = horizontalPadding),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.widthIn(max = maxWidth).fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(modifier = Modifier.padding(24.dp)) {
Text(title, style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(8.dp))
Text(content, style = MaterialTheme.typography.bodyLarge)
}
}
}
}
On phone: full width with 16dp margin. On tablet: centered card capped at 800dp. This prevents text lines from becoming unreadably long on wide screens.
Example 6: BoxWithConstraints for Fine-Grained Control
When you need exact pixel control:
@Composable
fun AdaptiveProfile() {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
if (maxWidth < 600.dp) {
// Compact — vertical layout
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ProfileAvatar(size = 100.dp)
Spacer(modifier = Modifier.height(16.dp))
ProfileInfo()
Spacer(modifier = Modifier.height(16.dp))
ProfileActions()
}
} else if (maxWidth < 900.dp) {
// Medium — avatar beside info
Row(modifier = Modifier.fillMaxSize().padding(24.dp)) {
ProfileAvatar(size = 120.dp)
Spacer(modifier = Modifier.width(24.dp))
Column {
ProfileInfo()
Spacer(modifier = Modifier.height(16.dp))
ProfileActions()
}
}
} else {
// Expanded — three column layout
Row(modifier = Modifier.fillMaxSize().padding(32.dp)) {
ProfileAvatar(size = 150.dp)
Spacer(modifier = Modifier.width(32.dp))
ProfileInfo(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(32.dp))
ProfileActions(modifier = Modifier.width(200.dp))
}
}
}
}
BoxWithConstraints gives you maxWidth and maxHeight as dp values inside the composable. Use it when window size classes are too coarse.
Foldable Support
Foldable devices have a hinge that can split the screen:
@Composable
fun FoldableAwareLayout() {
val windowInfo = currentWindowAdaptiveInfo()
val foldingFeature = windowInfo.windowPosture.foldingFeature
if (foldingFeature != null && foldingFeature.isSeparating) {
// Device is folded with a visible hinge
// Split content to avoid the hinge area
Row(modifier = Modifier.fillMaxSize()) {
// Left of hinge
Box(modifier = Modifier.weight(1f)) {
ListContent()
}
// Right of hinge
Box(modifier = Modifier.weight(1f)) {
DetailContent()
}
}
} else {
// Normal device or folded flat
SinglePaneLayout()
}
}
The good news: if you use ListDetailPaneScaffold and NavigationSuiteScaffold, foldable support is handled automatically. You only need custom hinge handling for very specific layouts.
Testing Adaptive Layouts
In Android Studio
- Use the Resizable Emulator — drag the window edges to test different sizes
- Use Device Manager — create emulators for different screen sizes:
- Phone: 6" (compact)
- Tablet: 10" (expanded)
- Foldable: 7.6" fold (compact ↔ expanded)
In Code
@Preview(name = "Phone", widthDp = 360, heightDp = 640)
@Preview(name = "Tablet", widthDp = 1024, heightDp = 768)
@Preview(name = "Foldable Open", widthDp = 840, heightDp = 900)
@Composable
fun AdaptivePreview() {
MyAppTheme {
AdaptiveApp()
}
}
Multiple @Preview annotations let you see all screen sizes at once in Android Studio.
Common Mistakes
Mistake 1: Checking Device Type Instead of Window Size
// BAD — doesn't handle multitasking, foldables, or desktop mode
val isTablet = resources.getBoolean(R.bool.isTablet)
// GOOD — adapts to actual available space
val widthClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass
Mistake 2: Hardcoding Width
// BAD — breaks on different screen sizes
Box(modifier = Modifier.width(360.dp))
// GOOD — adapts with constraints
Box(modifier = Modifier.fillMaxWidth().widthIn(max = 600.dp))
Mistake 3: Not Testing in Landscape
Many developers only test in portrait. Rotate the device — your compact layout might become medium, and your layout might break.
Mistake 4: Ignoring the Keyboard
When the soft keyboard opens, the available height shrinks dramatically. A layout that looks fine might not work with the keyboard visible.
// Use imePadding to handle keyboard
Column(
modifier = Modifier
.fillMaxSize()
.imePadding() // Adjusts for keyboard
) {
// Content moves up when keyboard appears
}
Quick Reference
When to Use Each Tool
| Tool | Use For |
|---|---|
WindowSizeClass | Broad layout decisions (1 column vs 2) |
BoxWithConstraints | Fine-grained pixel-level control |
ListDetailPaneScaffold | List-detail pattern (auto-adapts) |
NavigationSuiteScaffold | Navigation that adapts (bar/rail/drawer) |
LazyVerticalGrid(GridCells.Adaptive) | Responsive grids |
Modifier.widthIn(max = X) | Capping content width |
Dependencies
// Window size classes + adaptive info
implementation("androidx.compose.material3.adaptive:adaptive:1.1.0")
// List-detail scaffold
implementation("androidx.compose.material3.adaptive:adaptive-layout:1.1.0")
implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.1.0")
// Adaptive navigation (bar/rail/drawer)
implementation("androidx.compose.material3:material3-adaptive-navigation-suite")
Source Code
The complete working code for this tutorial is on GitHub:
Related Tutorials
- Tutorial #2: Layouts — Column, Row, Box basics
- Tutorial #8: Navigation — navigation that adaptive layouts build on
- Tutorial #6: Lists — LazyColumn and LazyVerticalGrid
- Jetpack Compose Cheat Sheet — quick reference for every component and modifier.
- Full Series: Jetpack Compose Tutorial — all 25 tutorials from zero to publishing.
What’s Next?
Part 3 (Advanced) is complete! You now know Animations, Canvas, Performance, Testing, Permissions, and Adaptive Layouts.
In Part 4, we will build a real app — a complete task manager using everything we learned.
See you there.