Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 068 / 100

Adaptive layouts for tablets and foldables: a deep dive

Use the space well.

Expona Admin10 min read

Adaptive layouts for tablets and foldables: a deep dive

Modern Android devices come in an increasingly diverse range of sizes and form factors—from compact phones to sprawling tablets and innovative foldables. Building an app that looks polished and functions smoothly across all these configurations is no longer optional; it's table stakes for any production Android application. In this article, we'll explore how to design and implement adaptive layouts for tablets and foldables, ensuring your UI makes intelligent use of the screen real estate available.

Why adaptive layouts matter

A few years ago, you could get away with a one-size-fits-all phone-centric design. Today, that approach leaves money and user satisfaction on the table. When someone opens your app on a tablet, they expect the interface to leverage the extra space—not just blow up phone-sized components. Similarly, foldables introduce discontinuities and hinge constraints that demand thoughtful layout strategies.

Adaptive layouts directly impact:

  • Engagement: A well-designed tablet experience encourages longer sessions and deeper feature exploration.
  • Retention: Users notice when their device's capabilities are respected; sloppy scaling tanks ratings.
  • Revenue: Tablet users often spend more and are more likely to engage with premium features or subscriptions.
  • Market coverage: Supporting foldables positions your app in the fastest-growing device category.

The good news: Android's framework provides robust tools to make this straightforward. The bad news: many teams skip it because they underestimate the effort or don't know where to start. We'll address both.

Window size classes

The foundation of adaptive layout design is understanding the actual viewport your app has to work with. The Material3 WindowSizeClass API provides a standardized way to categorize screen sizes without hard-coding pixel thresholds.

Instead of checking raw screen width (a brittle approach prone to edge cases), you define breakpoints based on device categories:

  • Compact: phones in portrait, typically < 600 dp wide.
  • Medium: tablets in portrait or large phones landscape, 600–840 dp.
  • Expanded: tablets in landscape, > 840 dp.

The library does the heavy lifting. First, add the dependency to your build.gradle:

kotlin
dependencies {
    implementation "androidx.compose.material3:material3-window-size-class:1.1.0"
}

Then integrate it into your root composable:

kotlin
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowSizeClass

@Composable
fun MyApp() {
    val windowSizeClass = calculateWindowSizeClass()
    
    when (windowSizeClass.widthSizeClass) {
        WindowWidthSizeClass.Compact -> {
            // Phone layout: single column
            PhoneLayout()
        }
        WindowWidthSizeClass.Medium -> {
            // Tablet portrait: two columns, sidebar
            TabletPortraitLayout()
        }
        WindowWidthSizeClass.Expanded -> {
            // Tablet landscape: rich two-column or three-column
            TabletLandscapeLayout()
        }
    }
}

This is deceptively powerful. You're no longer guessing or hard-coding breakpoints; you're letting Android tell you what category of device you're on, and you adapt accordingly. The classification even accounts for display density and orientation, so a tablet held vertically might report "Medium" while landscape reports "Expanded".

List-detail layouts

One of the most common patterns—and one of the highest-impact improvements—is the list-detail or master-detail layout. On a phone, you show a list, tap an item, and navigate to a detail view. On a tablet, you show the list on the left and the detail on the right simultaneously.

Let's build a realistic e-commerce product browser. On phones, it's a list of products with navigation to each detail page. On tablets, we show the list and detail side by side:

kotlin
data class Product(
    val id: String,
    val name: String,
    val price: Float,
    val description: String,
    val imageUrl: String
)

@Composable
fun ProductBrowser(
    products: List,
    windowSizeClass: WindowSizeClass
) {
    var selectedProductId by remember { mutableStateOf(null) }
    
    when (windowSizeClass.widthSizeClass) {
        WindowWidthSizeClass.Compact -> {
            // Phone: single column list, navigate on tap
            ProductListPhone(
                products = products,
                onProductSelected = { productId ->
                    selectedProductId = productId
                    // Navigate to detail screen
                }
            )
        }
        WindowWidthSizeClass.Medium, WindowWidthSizeClass.Expanded -> {
            // Tablet: list on left, detail on right
            Row(modifier = Modifier.fillMaxSize()) {
                ProductListColumn(
                    products = products,
                    selectedProductId = selectedProductId,
                    onProductSelected = { selectedProductId = it },
                    modifier = Modifier
                        .weight(1f)
                        .fillMaxHeight()
                )
                
                if (selectedProductId != null) {
                    Divider(
                        modifier = Modifier
                            .fillMaxHeight()
                            .width(1.dp),
                        color = Color.LightGray
                    )
                    ProductDetailColumn(
                        product = products.first { it.id == selectedProductId },
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxHeight()
                    )
                } else {
                    Box(
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxHeight()
                            .background(Color(0xFFF5F5F5)),
                        contentAlignment = Alignment.Center
                    ) {
                        Text("Select a product", color = Color.Gray)
                    }
                }
            }
        }
    }
}

@Composable
private fun ProductListColumn(
    products: List,
    selectedProductId: String?,
    onProductSelected: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier = modifier) {
        items(products) { product ->
            ProductListItem(
                product = product,
                isSelected = product.id == selectedProductId,
                onClick = { onProductSelected(product.id) }
            )
        }
    }
}

@Composable
private fun ProductListItem(
    product: Product,
    isSelected: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .clickable(onClick = onClick)
            .background(
                if (isSelected) Color(0xFFE8F5E9) else Color.White
            )
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        AsyncImage(
            model = product.imageUrl,
            contentDescription = product.name,
            modifier = Modifier
                .size(60.dp)
                .clip(RoundedCornerShape(4.dp)),
            contentScale = ContentScale.Crop
        )
        Spacer(modifier = Modifier.width(16.dp))
        Column(modifier = Modifier.weight(1f)) {
            Text(
                text = product.name,
                style = MaterialTheme.typography.bodyLarge,
                fontWeight = FontWeight.Bold
            )
            Text(
                text = "$${product.price}",
                style = MaterialTheme.typography.bodyMedium,
                color = Color.Gray
            )
        }
    }
}

@Composable
private fun ProductDetailColumn(
    product: Product,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier
            .padding(24.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        item {
            AsyncImage(
                model = product.imageUrl,
                contentDescription = product.name,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(300.dp)
                    .clip(RoundedCornerShape(8.dp)),
                contentScale = ContentScale.Crop
            )
        }
        item {
            Text(
                text = product.name,
                style = MaterialTheme.typography.headlineSmall
            )
        }
        item {
            Text(
                text = "$${product.price}",
                style = MaterialTheme.typography.headlineMedium,
                color = MaterialTheme.colorScheme.primary
            )
        }
        item {
            Text(
                text = product.description,
                style = MaterialTheme.typography.bodyMedium
            )
        }
        item {
            Button(
                onClick = { /* Add to cart */ },
                modifier = Modifier
                    .fillMaxWidth()
                    .height(48.dp)
            ) {
                Text("Add to Cart")
            }
        }
    }
}

The magic here is state preservation. When you rotate from portrait to landscape on a tablet, selectedProductId persists because it's held in a composable's state. The UI automatically reflows to show both list and detail. No navigation stack thrashing, no back button confusion.

Navigation UI itself must adapt. A bottom navigation bar (common on phones) doesn't make sense on a tablet landscape—that real estate is better used for content. Material3 provides NavigationSuiteScaffold to handle this intelligently.

NavigationSuiteScaffold automatically switches between:

  • Navigation bar (bottom) on compact screens.
  • Navigation rail (left sidebar) on medium screens.
  • Navigation drawer (large left panel) on expanded screens.

Here's a production-ready example for a multi-section app:

kotlin
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold

enum class AppSection {
    HOME, PRODUCTS, ORDERS, ACCOUNT
}

@Composable
fun MainApp(windowSizeClass: WindowSizeClass) {
    var currentSection by remember { mutableStateOf(AppSection.HOME) }
    
    NavigationSuiteScaffold(
        navigationSuiteItems = {
            navigationSuiteItem(
                icon = Icons.Filled.Home,
                label = "Home",
                selected = currentSection == AppSection.HOME,
                onClick = { currentSection = AppSection.HOME }
            )
            navigationSuiteItem(
                icon = Icons.Filled.ShoppingCart,
                label = "Products",
                selected = currentSection == AppSection.PRODUCTS,
                onClick = { currentSection = AppSection.PRODUCTS }
            )
            navigationSuiteItem(
                icon = Icons.Filled.Receipt,
                label = "Orders",
                selected = currentSection == AppSection.ORDERS,
                onClick = { currentSection = AppSection.ORDERS }
            )
            navigationSuiteItem(
                icon = Icons.Filled.Person,
                label = "Account",
                selected = currentSection == AppSection.ACCOUNT,
                onClick = { currentSection = AppSection.ACCOUNT }
            )
        }
    ) {
        when (currentSection) {
            AppSection.HOME -> HomeScreen()
            AppSection.PRODUCTS -> ProductsScreen()
            AppSection.ORDERS -> OrdersScreen()
            AppSection.ACCOUNT -> AccountScreen()
        }
    }
}

Under the hood, NavigationSuiteScaffold uses window size class to pick the right navigation pattern. You define the items once; the framework handles the layout switching. This eliminates a huge source of complexity and bugs.

Test on resizable emulators

Theory is useful, but you must validate on real devices and realistic simulators. Android Studio's Resizable Device Configuration is essential here.

To test adaptivity without owning every device:

  1. Create a resizable emulator: In Device Manager, select "Resizable" under device type. This gives you a virtual device you can drag to any size.
  2. Toggle orientation: Rotate the emulator to test portrait and landscape. Watch your layout reflow in real time.
  3. Test intermediate sizes: Drag the window to 600 dp, 700 dp, 840 dp, etc. Verify your breakpoints work correctly and there are no awkward in-between states.
  4. Check foldable simulations: Android Studio offers foldable emulator presets (like "Foldable" or "7.4 Fold"). These simulate hinge detection and fold state changes.
  5. Monitor state preservation: Rotate and resize while interacting with your app. Ensure state doesn't drop, navigation doesn't reset unexpectedly, and animations don't stutter.

Here's a quick tip: add a debug overlay that shows the current window size class during development:

kotlin
@Composable
fun DebugWindowInfo(windowSizeClass: WindowSizeClass) {
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .background(Color.Black.copy(alpha = 0.7f))
            .padding(8.dp)
    ) {
        Text(
            text = "Width: ${windowSizeClass.widthSizeClass.name} | " +
                    "Height: ${windowSizeClass.heightSizeClass.name}",
            color = Color.White,
            style = MaterialTheme.typography.labelSmall,
            modifier = Modifier.align(Alignment.Center)
        )
    }
}

Slap this at the top of your screen and you'll instantly see which breakpoint you're in as you resize. It saves debugging time and builds confidence that your thresholds are correct.

Common mistakes

Hard-coding pixel values: Resist the urge to write code like if (screenWidth > 600) { /* tablet */ }. Use WindowSizeClass instead. It's density-aware and future-proof.

Ignoring height: Many developers focus only on width. But window height matters too—especially on tablets held in portrait, or when the keyboard is open. Use windowSizeClass.heightSizeClass to adapt vertical layouts.

Static navigation decisions: Switching from BottomAppBar to NavigationRail is a common need, but implementing it manually is error-prone. NavigationSuiteScaffold handles it; use it.

Not testing foldables: Foldables are no longer niche. If your app doesn't work smoothly across the fold line, you're missing a growing segment. Test with emulator foldable configs.

Forgetting landscape: Tablets spend plenty of time in landscape. If your adaptive layouts only address portrait, you've solved half the problem. Test both.

Key takeaways

  • Window size classes provide a standardized, maintainable way to categorize screen sizes. Use them instead of raw pixel checks.
  • List-detail layouts are high-impact. Showing list and detail simultaneously on tablets dramatically improves the user experience and engagement.
  • NavigationSuiteScaffold automates navigation pattern switching. Let the framework choose between bottom nav, nav rail, and drawer based on screen size.
  • Resizable emulators are your friend. Test your layouts at multiple sizes and in both orientations before shipping.
  • Foldable support is table stakes now. Include foldable emulator testing in your QA process.
  • Adaptive design isn't an afterthought—it's a core competency. Plan for it from day one, not day ninety-nine.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles