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 ClassSizeExamples
Compact< 600dpPhones in portrait
Medium600-840dpPhones in landscape, small tablets
Expanded> 840dpTablets, 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:

// 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 codeNavigationSuiteScaffold 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

  1. Use the Resizable Emulator — drag the window edges to test different sizes
  2. 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

ToolUse For
WindowSizeClassBroad layout decisions (1 column vs 2)
BoxWithConstraintsFine-grained pixel-level control
ListDetailPaneScaffoldList-detail pattern (auto-adapts)
NavigationSuiteScaffoldNavigation 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:

View source code on GitHub →

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.