Android Adaptive Layouts: Your App on Foldables, Tablets, and Everything in Between
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.
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:
| Class | Width | Height |
|---|---|---|
| Compact | < 600dp | < 480dp |
| Medium | 600dp – 840dp | 480dp – 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.
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.
NavigationSuiteScaffold: One Component, Three Nav Patterns
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.
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=falsestops 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-classdependency - Implement
calculateWindowSizeClassin yourMainActivity - Pass
WindowSizeClassdown to your top-level composable - Replace bottom nav with
NavigationSuiteScaffold
Week 2 — Core Screens
- Identify your highest-traffic screen
- Implement
ListDetailPaneScaffoldif 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
WindowInfoTrackerflow to yourViewModel - Handle
HALF_OPENEDtabletop posture for media/camera screens - Test fold/unfold state preservation manually
Week 4 — Validation
- Run through Resizable Emulator in all four configurations
- Add
@Previewannotations 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.
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
ListDetailPaneScaffoldhandles two-pane navigation state, back gestures, and fold/unfold transitions automaticallyNavigationSuiteScaffoldauto-switches between bottom bar, rail, and drawer based on width — no manual branchingFoldingFeatureexposesFLATandHALF_OPENEDstates; no hinge angle — design for the stateViewModel+SavedStateHandleis mandatory for any UI state that must survive fold/unfold configuration changes- Android 17 removes the
resizeableActivity=falseescape 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
Android App Onboarding UX: 7 Patterns That Cut Churn by 50%
97.9% of Android users churn by Day 30. These onboarding UX patterns from Duolingo, Headspace, and Notion fight back with data-proven results.
Android Bottom Navigation vs Navigation Drawer: How to Choose the Right Pattern
Bottom nav for 3-5 destinations, drawer for 6+. Material Design 3 guidelines, thumb zones, and real examples from Gmail, Instagram, and Maps.
Android Deep Linking Best Practices for App Growth in 2026
Firebase Dynamic Links shut down in Aug 2025 — 30% of deep links are now failing. Here's how to fix them and drive 2-3x higher conversions.