Go back

Building Adaptive Layouts in Jetpack Compose

Android

Build responsive list-detail interfaces in Jetpack Compose by adapting to the current window size instead of the device type.

Most Android apps start with one screen size in mind: a phone in portrait mode. That is fine for the first version, but it does not age well.

Your app can run on phones, tablets, foldables, ChromeOS, split-screen mode, and resizable windows. The same device can also change size while your app is running. A foldable can open. A tablet app can move into split-screen. A phone can rotate.

That is why adaptive layout should not ask:

Is this device a tablet?

It should ask:

How much space does my app window have right now?

In Jetpack Compose, the main tool for that is WindowSizeClass. You can use it to decide whether your UI should show one pane, two panes, a bottom navigation bar, a navigation rail, or some other layout.

In this article, we will build a simple adaptive list-detail screen. On small screens, the user sees either the list or the detail. On larger screens, the list and detail appear side by side.

What we are building

Imagine a simple mail app.

On a phone, it works like this:

  1. The user sees a list of emails.
  2. They tap one email.
  3. The detail screen opens.
  4. They press back to return to the list.

On a tablet or large window, it works like this:

  1. The list stays visible on the left.
  2. The selected email appears on the right.
  3. The user can switch between emails without losing context.

This is called a list-detail layout. It is one of the most useful adaptive patterns because it gives large screens a real purpose instead of just stretching the phone UI.

Add the dependencies

You need Material 3 and the adaptive libraries.

dependencies {
implementation("androidx.compose.material3:material3:<version>")
implementation("androidx.compose.material3:material3-window-size-class:<version>")
implementation("androidx.compose.material3.adaptive:adaptive:<version>")
implementation("androidx.compose.material3.adaptive:adaptive-layout:<version>")
implementation("androidx.compose.material3.adaptive:adaptive-navigation:<version>")
}

Use the latest versions available in your project.

Reading the window size

The adaptive library gives you access to the current window information from Compose:

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMailScreen() {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val width = windowSizeClass.widthSizeClass
val height = windowSizeClass.heightSizeClass
}

The width can usually be treated as:

WindowWidthSizeClass.Compact
WindowWidthSizeClass.Medium
WindowWidthSizeClass.Expanded

A simple rule is:

val showTwoPane =
width == WindowWidthSizeClass.Expanded &&
height != WindowHeightSizeClass.Compact

The height check matters. A landscape phone can have enough width, but very little height. Showing two panes there often feels cramped.

Create the model

We will keep the example small.

data class Mail(
val id: Long,
val sender: String,
val subject: String,
val body: String
)

And some fake data:

private fun sampleMails(): List<Mail> = List(12) { index ->
Mail(
id = index.toLong(),
sender = "Sender ${index + 1}",
subject = "Message ${index + 1}",
body = "This is the body for message ${index + 1}."
)
}

The adaptive screen

Now we can build the root screen.

The important state is selectedMailId. On small screens, if it is null, we show the list. If it is not null, we show the detail. On large screens, we show both.

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveMailScreen() {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val width = windowSizeClass.widthSizeClass
val height = windowSizeClass.heightSizeClass
val showTwoPane =
width == WindowWidthSizeClass.Expanded &&
height != WindowHeightSizeClass.Compact
val mails = remember { sampleMails() }
var selectedMailId by rememberSaveable {
mutableStateOf<Long?>(null)
}
val selectedMail = mails.firstOrNull {
it.id == selectedMailId
}
LaunchedEffect(showTwoPane) {
if (showTwoPane && selectedMailId == null) {
selectedMailId = mails.firstOrNull()?.id
}
}
if (!showTwoPane && selectedMail != null) {
BackHandler {
selectedMailId = null
}
}
if (showTwoPane) {
TwoPaneMailContent(
mails = mails,
selectedMail = selectedMail,
selectedMailId = selectedMailId,
onMailSelected = { selectedMailId = it }
)
} else {
SinglePaneMailContent(
mails = mails,
selectedMail = selectedMail,
onMailSelected = { selectedMailId = it },
onBack = { selectedMailId = null }
)
}
}

This is the core of the adaptive behavior.

The UI does not need separate screens for phones and tablets. The same state drives both layouts.

Single-pane content

The single-pane version is the phone layout.

It shows either the list or the detail.

@Composable
private fun SinglePaneMailContent(
mails: List<Mail>,
selectedMail: Mail?,
onMailSelected: (Long) -> Unit,
onBack: () -> Unit
) {
if (selectedMail == null) {
MailList(
mails = mails,
selectedMailId = null,
onMailSelected = onMailSelected,
modifier = Modifier.fillMaxSize()
)
} else {
MailDetail(
mail = selectedMail,
showBackButton = true,
onBack = onBack,
modifier = Modifier.fillMaxSize()
)
}
}

This keeps the phone experience simple. There is no need to show a tiny detail pane beside the list.

Two-pane content

The two-pane version is for large windows.

@Composable
private fun TwoPaneMailContent(
mails: List<Mail>,
selectedMail: Mail?,
selectedMailId: Long?,
onMailSelected: (Long) -> Unit
) {
Row(Modifier.fillMaxSize()) {
MailList(
mails = mails,
selectedMailId = selectedMailId,
onMailSelected = onMailSelected,
modifier = Modifier.weight(0.4f)
)
VerticalDivider()
MailDetail(
mail = selectedMail,
showBackButton = false,
onBack = null,
modifier = Modifier.weight(0.6f)
)
}
}

The list takes 40% of the width. The detail takes 60%.

This is not a rule. It is just a good starting point. For content-heavy apps, the detail pane may need more space. For list-heavy apps, the list may deserve more.

The list

Here is the email list:

@Composable
private fun MailList(
mails: List<Mail>,
selectedMailId: Long?,
onMailSelected: (Long) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.padding(8.dp)
) {
items(
items = mails,
key = { it.id }
) { mail ->
val selected = mail.id == selectedMailId
ListItem(
headlineContent = {
Text(mail.subject)
},
supportingContent = {
Text(mail.sender)
},
modifier = Modifier
.fillMaxWidth()
.clickable {
onMailSelected(mail.id)
},
colors = ListItemDefaults.colors(
containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
}
)
)
}
}
}

The selected item is highlighted only when there is a selected item. On phones, this usually does not matter because the list is not visible while the detail is open. On larger screens, it helps the user understand which item they are reading.

The detail screen

The detail pane can be reused in both layouts.

@Composable
private fun MailDetail(
mail: Mail?,
showBackButton: Boolean,
onBack: (() -> Unit)?,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(24.dp)
) {
if (showBackButton && onBack != null) {
TextButton(onClick = onBack) {
Text("Back")
}
Spacer(Modifier.height(8.dp))
}
if (mail == null) {
Text(
text = "Select an email",
style = MaterialTheme.typography.titleMedium
)
return
}
Text(
text = mail.subject,
style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(8.dp))
Text(
text = "From: ${mail.sender}",
style = MaterialTheme.typography.labelLarge
)
Spacer(Modifier.height(16.dp))
Text(
text = mail.body,
style = MaterialTheme.typography.bodyLarge
)
}
}

Notice that the back button is optional. On phones, we need it. On large screens, we do not, because the list is already visible.

Adding adaptive navigation

The same idea applies to app navigation.

On compact screens, bottom navigation usually works well. On medium and expanded screens, a navigation rail often works better.

A simple manual version looks like this:

@Composable
fun AdaptiveAppShell(
useBottomBar: Boolean,
content: @Composable Modifier.() -> Unit
) {
if (useBottomBar) {
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = true,
onClick = {},
icon = { Icon(Icons.Default.Home, null) },
label = { Text("Home") }
)
}
}
) { padding ->
Box(
modifier = Modifier
.padding(padding)
.content()
)
}
} else {
Row(Modifier.fillMaxSize()) {
NavigationRail {
NavigationRailItem(
selected = true,
onClick = {},
icon = { Icon(Icons.Default.Home, null) },
label = { Text("Home") }
)
}
Box(
modifier = Modifier
.weight(1f)
.content()
)
}
}
}

In a real app, you can also use NavigationSuiteScaffold. It chooses a suitable navigation component based on the window size. That is usually better than writing this logic yourself for every screen.

When to use the official adaptive scaffolds

The manual version is useful because it shows what is happening.

For production apps, you should consider the Material 3 adaptive scaffolds:

ListDetailPaneScaffold
NavigableListDetailPaneScaffold
NavigationSuiteScaffold

Use the manual approach when your layout is simple and you want full control.

Use NavigableListDetailPaneScaffold when:

  • The detail pane has its own navigation.
  • Back behavior gets more complex.
  • You need pane-aware save and restore.
  • You want a more standard large-screen behavior.

For many apps, I would start with manual logic like the example above. Once the screen grows, move to the official scaffold.

Common mistakes

The first mistake is checking the device type.

if (isTablet) {
// show tablet UI
}

This is fragile. A tablet can run your app in a small split-screen window. A foldable can change size. A phone can be in landscape. The current window size is what matters.

The second mistake is only checking width.

val showTwoPane = width == WindowWidthSizeClass.Expanded

This can break on short landscape windows. Add a height check when the two-pane layout needs vertical space.

val showTwoPane =
width == WindowWidthSizeClass.Expanded &&
height != WindowHeightSizeClass.Compact

The third mistake is stretching the phone UI.

A large screen should not just show the same list with huge empty space. It should use the extra room to reduce navigation, preserve context, and make the app faster to use.

The fourth mistake is duplicating too much UI.

You usually do not need a separate phone screen and tablet screen. Keep the same state and reuse the same list/detail composables. Only change how they are arranged.

Final thoughts

Adaptive layout is not about supporting tablets as a separate feature. It is about making your UI respond to the space it has right now.

The main pattern is simple:

val showTwoPane =
width == WindowWidthSizeClass.Expanded &&
height != WindowHeightSizeClass.Compact

From there, keep your state at the screen root, reuse your composables, and change only the layout structure.

Small screens get one pane. Large screens get two panes. The user gets a better app without you maintaining two separate versions of the same UI.