Android Adaptive Layouts: Your App on Foldables, Tablets, and Everything in Between

AppBooster Team · · 12 min read
Samsung Galaxy Z Fold showing split screen app layout

Your app looks great on a Pixel 8. You’re proud of it. Then someone opens it on a Samsung Galaxy Z Fold unfolded to its full 7.6-inch display and your meticulously designed UI stretches into a horror show — single-column layout spanning the full width, touch targets the size of dinner plates, content that was meant to fill a phone screen now marooned in the center of a tablet-sized canvas.

Here’s the uncomfortable truth: that’s no longer a niche edge case.

There are over 300 million large-screen Android devices active today. Foldables grew 30% year-over-year in 2025. Book-style foldables jumped from 52% to 65% market share in a single year. And with Android 17, Google is removing the opt-out — apps targeting devices with shortest width greater than 600dp must support adaptive layouts. No override flags. No escape hatch.

The question isn’t whether you need to handle large screens. It’s whether you do it before or after your 1-star reviews start mentioning “tablet mode.”


Why Adaptive Layouts Are Hard (and Why Most Teams Ignore Them)

The usual excuse is “our users are all on phones.” That was true in 2019. Today, Samsung’s Galaxy Tab line outsells many flagship phone lines in certain markets. Chromebooks run Android apps. Foldables are mainstream enough that major carriers stock them without explanation.

The real reason teams skip adaptive work is that it feels like a separate project — a parallel universe of UI that doubles your layout complexity. That was true when the only tool was resource qualifiers and separate layout XML files for every screen size.

Jetpack Compose and the Material 3 adaptive library changed the equation. The primitives exist now. The work is mostly about understanding three concepts and applying them consistently.

Android device ecosystem showing phones, foldables, and tablets side by side


Concept 1: Window Size Classes

Forget pixel densities. Forget specific device names. Android’s adaptive system is built on window size classes — abstract breakpoints that describe the available space your app has right now, regardless of what device it’s running on.

There are two axes (width and height), each with three classes:

ClassWidthHeight
Compact< 600dp< 480dp
Medium600dp – 840dp480dp – 900dp
Expanded≥ 840dp≥ 900dp

In practice, width drives most layout decisions. A phone in portrait is Compact. A phone in landscape is Medium. A tablet or unfolded foldable is Expanded. A foldable in tabletop posture might be Medium height with Expanded width.

Here’s how you read the current window size class in Compose:

import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass

@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val windowSizeClass = calculateWindowSizeClass(this)
            MyApp(windowSizeClass = windowSizeClass)
        }
    }
}

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    val showNavigationRail = windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact

    if (showNavigationRail) {
        AppWithRail()
    } else {
        AppWithBottomBar()
    }
}

One function call. One branch. That’s the entry point to your entire adaptive strategy.

Critical detail: calculateWindowSizeClass must be called from an Activity context, not a ViewModel or repository. It measures the current window, not the screen — so in multi-window mode, you get the actual space your app occupies, not the full display dimensions.


Concept 2: Canonical Layouts

Window size classes tell you how much space you have. Canonical layouts tell you what to do with it.

Google defines three patterns that cover the majority of app UI needs:

List-Detail — Master list on the left, content panel on the right. Used by Gmail, Settings, most content apps. On Compact, show one pane at a time. On Expanded, show both simultaneously.

Feed — Single-column on Compact, multi-column grid on larger screens. Used by Photos, news apps, social feeds.

Supporting Pane — Primary content with a contextual side panel. Used by Maps (directions pane), Docs (outline panel), media players with queue.

The Material 3 adaptive library provides ListDetailPaneScaffold that handles the most complex one — pane navigation state — so you don’t implement it by hand.


ListDetailPaneScaffold: Stop Reimplementing Pane Navigation

This is where most teams waste the most time — rolling their own back-stack logic for two-pane layouts, then discovering edge cases on fold/unfold, then giving up and shipping the broken version.

ListDetailPaneScaffold manages pane visibility, navigation state, and back gestures:

import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun EmailScreen() {
    val navigator = rememberListDetailPaneScaffoldNavigator<EmailItem>()

    BackHandler(navigator.canNavigateBack()) {
        navigator.navigateBack()
    }

    ListDetailPaneScaffold(
        directive = navigator.scaffoldDirective,
        value = navigator.scaffoldValue,
        listPane = {
            AnimatedPane {
                EmailList(
                    onEmailSelected = { email ->
                        navigator.navigateTo(
                            pane = ListDetailPaneScaffoldRole.Detail,
                            content = email
                        )
                    }
                )
            }
        },
        detailPane = {
            AnimatedPane {
                val email = navigator.currentDestination?.content
                if (email != null) {
                    EmailDetail(email = email)
                } else {
                    EmptyDetailPlaceholder()
                }
            }
        }
    )
}

What this handles automatically:

  • On Compact: shows list, navigates to detail on tap, back gesture returns to list
  • On Expanded: shows both panes side-by-side, no navigation needed
  • On fold/unfold: transitions between single-pane and dual-pane smoothly
  • Back gesture: navigator.canNavigateBack() returns false when both panes are visible (nowhere to go back to), true in single-pane detail view

The content parameter on navigateTo is typed — pass your data model directly, no need for string keys or bundle serialization.

Code on a monitor representing Android development


Concept 3: Foldable Posture Handling

Foldables introduce a state that tablets never had: the hinge.

When a book-style foldable is partially open, it can sit on a table like a laptop — top half showing content, bottom half showing controls. Samsung calls it Flex Mode. Google calls it tabletop posture. The Jetpack WindowManager library calls it FoldingFeature.State.HALF_OPENED.

One thing to know upfront: the API does not expose hinge angle as a number. You get two states — FLAT (fully open) and HALF_OPENED (partially open at some angle). Design for the state, not a specific angle.

import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

// In your ViewModel
val devicePosture: StateFlow<DevicePosture> = WindowInfoTracker
    .getOrCreate(context)
    .windowLayoutInfo(activity)
    .map { layoutInfo ->
        val foldingFeature = layoutInfo.displayFeatures
            .filterIsInstance<FoldingFeature>()
            .firstOrNull()

        when {
            foldingFeature == null -> DevicePosture.Normal
            foldingFeature.state == FoldingFeature.State.HALF_OPENED &&
            foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL ->
                DevicePosture.TableTop(foldingFeature.bounds)
            foldingFeature.state == FoldingFeature.State.HALF_OPENED &&
            foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL ->
                DevicePosture.BookMode(foldingFeature.bounds)
            else -> DevicePosture.Normal
        }
    }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.Eagerly,
        initialValue = DevicePosture.Normal
    )

sealed class DevicePosture {
    object Normal : DevicePosture()
    data class TableTop(val hingeBounds: Rect) : DevicePosture()
    data class BookMode(val hingeBounds: Rect) : DevicePosture()
}

Use this in your composable to adapt the layout:

@Composable
fun VideoPlayerScreen(posture: DevicePosture) {
    when (posture) {
        is DevicePosture.TableTop -> {
            // Split at hinge: video on top, controls on bottom
            Column {
                VideoPlayer(modifier = Modifier.weight(1f))
                PlayerControls(modifier = Modifier.weight(1f))
            }
        }
        else -> {
            // Standard layout
            Box {
                VideoPlayer(modifier = Modifier.fillMaxSize())
                PlayerControls(modifier = Modifier.align(Alignment.BottomCenter))
            }
        }
    }
}

The hingeBounds tells you exactly where the hinge is in the window coordinate space — useful if you need to avoid placing interactive elements directly over it.


Navigation is where the most visible adaptive failures happen. Bottom navigation bar on a 12-inch tablet is awkward. A navigation drawer on a phone is buried.

NavigationSuiteScaffold from Material 3 switches automatically:

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

@Composable
fun AdaptiveNavigation(
    currentDestination: AppDestination,
    onDestinationChange: (AppDestination) -> Unit,
    content: @Composable () -> Unit
) {
    NavigationSuiteScaffold(
        navigationSuiteItems = {
            AppDestination.entries.forEach { destination ->
                item(
                    icon = { Icon(destination.icon, contentDescription = destination.label) },
                    label = { Text(destination.label) },
                    selected = currentDestination == destination,
                    onClick = { onDestinationChange(destination) }
                )
            }
        }
    ) {
        content()
    }
}

The scaffold automatically renders:

  • Bottom bar on Compact width
  • Navigation rail on Medium width
  • Navigation drawer on Expanded width

No manual WindowSizeClass checks. No conditional rendering. One component, adaptive by default.


The Pain Point Nobody Warns You About: State Loss on Fold

This one will bite you.

When a user unfolds a foldable while your app is running, Android may destroy and recreate the activity — treating it like a configuration change. If you’re storing UI state in remember {} without a backed state holder, it’s gone.

The fix is the same as rotation handling, but fold/unfold is less obvious because developers rarely test it:

// Wrong: state lost on fold/unfold
@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }
    // ...
}

// Right: survives config changes including fold/unfold
class SearchViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    var query by savedStateHandle.saveable(
        stateSaver = TextFieldValue.Saver
    ) { mutableStateOf(TextFieldValue("")) }
}

@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    var query by viewModel.query
    // ...
}

SavedStateHandle survives process death, rotation, and fold/unfold. For anything the user has typed or selected, it belongs there — not in remember.

The other fold-specific pain point is camera preview orientation. Camera2 and CameraX preview streams have their own rotation metadata separate from the display orientation. On foldables switching between inner and outer screens, this can flip unexpectedly. CameraX’s Preview.Builder with setTargetRotation tied to the display rotation listener handles it — but it requires explicit wiring that most tutorials skip.


Testing Without a Foldable Device

Most developers don’t have every form factor on their desk. You don’t need them.

Android Studio Device Mirroring + Resizable Emulator: The Resizable AVD lets you switch between phone, foldable (folded), foldable (unfolded), and tablet mid-session. Run your app once, test all four configurations.

WindowSizeClass previews: Add @Preview annotations with different window sizes to catch layout issues without running the app:

@Preview(name = "Phone", widthDp = 360, heightDp = 800)
@Preview(name = "Foldable", widthDp = 673, heightDp = 841)
@Preview(name = "Tablet", widthDp = 1280, heightDp = 800)
@Composable
fun MyScreenPreview() {
    MyScreen(windowSizeClass = /* inject size class */)
}

Espresso with Robolectric: Window size class logic is pure Kotlin — unit test it. Mock the size class, assert the correct layout branch runs. No device required.

Developer testing app on multiple screen sizes


The Android 17 Mandate: What It Actually Means

Starting with Android 17, apps targeting devices with sw > 600dp — the smallest width qualifier that covers tablets and unfolded foldables — cannot use android:resizeableActivity="false" to force letterboxing. The system will ignore the flag.

What that means practically:

  • Your app will be stretched to fill the available window
  • If your layouts aren’t adaptive, you’ll get the stretched horror show described at the top of this post
  • There’s no override flag, no compatibility mode escape hatch

Google has been signaling this for two years. The Play Store already surfaces adaptive compatibility badges. Apps that handle large screens well get featured placement in tablet and foldable sections of the store. If you’re optimizing for visibility and installs — which is the whole game with tools like AppBooster — adaptive layout support is now a distribution variable, not just a UX nicety.

The enforcement timeline:

  • Now: Adaptive apps get featured placement and compatibility badges
  • Android 17: resizeableActivity=false stops working on large-screen devices
  • Play Store (rolling): Large-screen quality checks factor into store ranking

Implementation Checklist

Start here. Don’t over-engineer it:

Week 1 — Foundations

  • Add androidx.compose.material3:material3-window-size-class dependency
  • Implement calculateWindowSizeClass in your MainActivity
  • Pass WindowSizeClass down to your top-level composable
  • Replace bottom nav with NavigationSuiteScaffold

Week 2 — Core Screens

  • Identify your highest-traffic screen
  • Implement ListDetailPaneScaffold if it’s a list/detail pattern
  • Add multi-column grid for feed screens on Expanded width
  • Move critical UI state to ViewModel + SavedStateHandle

Week 3 — Foldable Polish

  • Add WindowInfoTracker flow to your ViewModel
  • Handle HALF_OPENED tabletop posture for media/camera screens
  • Test fold/unfold state preservation manually

Week 4 — Validation

  • Run through Resizable Emulator in all four configurations
  • Add @Preview annotations for all three width classes
  • Check Play Console’s large-screen dashboard for your app

What You’re Actually Signing Up For

Adaptive layout work isn’t a refactor of your entire codebase. The Material 3 adaptive library does the heavy lifting. The investment is front-loaded — understanding the three concepts, adopting the scaffold components, migrating state to survive configuration changes.

Once that foundation exists, adding support for a new form factor is a branch condition, not a separate app. Your existing Compose UI — the composables, the state management, the navigation — all carry over. You’re just teaching it to make different decisions based on how much space it has.

Modern app displayed beautifully on a large Android tablet

Three hundred million large-screen devices. Android 17 enforcement coming. Foldable growth compounding at 30% annually.

The app that handles this well doesn’t just avoid bad reviews. It earns featured placement, higher install conversion on large-screen-specific searches, and a user experience your competitors haven’t shipped yet. AppBooster tracks the store metrics that matter — adaptive apps consistently outperform their non-adaptive counterparts in the quality tiers that drive organic visibility.

The window size classes are defined. The scaffold components are stable. The only question left is when you start.


Key Takeaways

  • Window size classes (Compact / Medium / Expanded) are the single source of truth for layout decisions — measure window space, not screen size
  • ListDetailPaneScaffold handles two-pane navigation state, back gestures, and fold/unfold transitions automatically
  • NavigationSuiteScaffold auto-switches between bottom bar, rail, and drawer based on width — no manual branching
  • FoldingFeature exposes FLAT and HALF_OPENED states; no hinge angle — design for the state
  • ViewModel + SavedStateHandle is mandatory for any UI state that must survive fold/unfold configuration changes
  • Android 17 removes the resizeableActivity=false escape hatch — adaptive support is no longer optional

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles