| name | adaptive-ui |
| description | Adaptive UI for Android — responsive layouts for phones, tablets, foldables, Chromebooks,
and large screens. Use this skill for EVERY screen you build. Triggers on: WindowSizeClass,
ListDetailPaneScaffold, SupportingPaneScaffold, ThreePaneScaffold, NavigationSuiteScaffold,
NavigationRail, NavigationDrawer, adaptive layout, responsive, tablet layout, foldable,
FoldingFeature, two-pane, large screen, GridCells.Adaptive, WindowWidthSizeClass,
WindowHeightSizeClass, landscape, Chromebook, bottom sheet to side sheet, dialog sizing,
content width, LazyVerticalGrid, breakpoints, hinge, fold state. Apply to EVERY screen
— AI agents without this produce phone-only apps on 1B+ large-screen Android devices.
|
Adaptive UI — Every Screen, Every Device
Android has 1B+ active large-screen devices. Every screen must work on phones, tablets,
foldables, and Chromebooks. These 10 rules make that happen automatically.
The golden rule
Before placing any layout element: ask how it looks at 600dp width AND 840dp width.
If the answer is "one stretched column" — it's wrong.
Setup — required dependencies
[versions]
material3Adaptive = "1.1.0"
[libraries]
material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "material3Adaptive" }
material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "material3Adaptive" }
material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "material3Adaptive" }
windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "material3" }
Rule 1: NavigationSuiteScaffold — zero-code adaptive navigation
@Composable
fun MyApp() {
val navController = rememberNavController()
val currentEntry by navController.currentBackStackEntryAsState()
NavigationSuiteScaffold(
navigationSuiteItems = {
TopLevelRoute.entries.forEach { route ->
item(
selected = currentEntry?.destination?.hasRoute(route.routeClass) == true,
onClick = { navController.navigateTopLevel(route) },
icon = {
Icon(
if (currentEntry?.destination?.hasRoute(route.routeClass) == true)
route.selectedIcon else route.icon,
contentDescription = stringResource(route.labelRes)
)
},
label = { Text(stringResource(route.labelRes)) },
badge = if (route.badgeCount > 0) {
{ Badge { Text("${route.badgeCount}") } }
} else null
)
}
}
) {
AppNavHost(navController)
}
}
enum class TopLevelRoute(
val routeClass: KClass<*>,
val icon: ImageVector,
val selectedIcon: ImageVector,
@StringRes val labelRes: Int,
val badgeCount: Int = 0
) {
Home(HomeRoute::class, Icons.Outlined.Home, Icons.Filled.Home, R.string.home),
Search(SearchRoute::class, Icons.Outlined.Search, Icons.Filled.Search, R.string.search),
Inbox(InboxRoute::class, Icons.Outlined.Inbox, Icons.Filled.Inbox, R.string.inbox, badgeCount = 3),
Profile(ProfileRoute::class, Icons.Outlined.Person, Icons.Filled.Person, R.string.profile),
}
fun NavController.navigateTopLevel(route: TopLevelRoute) {
navigate(route.routeClass.objectInstance ?: return) {
popUpTo(graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
Rule 2: ListDetailPaneScaffold — master-detail for all list screens
@Composable
fun ItemsScreen() {
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
BackHandler(navigator.canNavigateBack()) {
navigator.navigateBack()
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
ItemListPane(
onItemSelected = { id ->
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, id)
}
)
}
},
detailPane = {
AnimatedPane {
val itemId = navigator.currentDestination?.content
if (itemId != null) {
ItemDetailPane(itemId = itemId)
} else {
Box(Modifier.fillMaxSize(), Alignment.Center) {
Text("Select an item", style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
)
}
Rule 3: WindowSizeClass breakpoints — 3 layout tiers
@Composable
fun MyScreen() {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isCompact = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
val isMedium = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.MEDIUM
val isExpanded = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED
when {
isExpanded -> ExpandedLayout()
isMedium -> MediumLayout()
else -> CompactLayout()
}
}
val WindowSizeClass.isCompact get() = windowWidthSizeClass == WindowWidthSizeClass.COMPACT
val WindowSizeClass.isMedium get() = windowWidthSizeClass == WindowWidthSizeClass.MEDIUM
val WindowSizeClass.isExpanded get() = windowWidthSizeClass == WindowWidthSizeClass.EXPANDED
val WindowSizeClass.isShortLandscape get() =
windowHeightSizeClass == WindowHeightSizeClass.COMPACT && !isCompact
Rule 4: Adaptive Grid — fills screen width automatically
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 180.dp),
contentPadding = PaddingValues(Spacing.md),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm),
verticalArrangement = Arrangement.spacedBy(Spacing.sm)
) {
items(items, key = { it.id }) { item ->
ItemCard(item, modifier = Modifier.fillMaxWidth())
}
}
@Composable
fun DesignControlledGrid(items: List<Item>, windowSizeClass: WindowSizeClass) {
val columns = when {
windowSizeClass.isExpanded -> GridCells.Fixed(4)
windowSizeClass.isMedium -> GridCells.Fixed(3)
else -> GridCells.Fixed(2)
}
LazyVerticalGrid(columns = columns) { }
}
Rule 5: Foldable support — FoldingFeature states
@Composable
fun FoldAwareScreen() {
val windowInfo = currentWindowAdaptiveInfo()
val posture = windowInfo.windowPosture
val hinges = posture.hingeList
val foldingFeature = hinges.firstOrNull()
when {
foldingFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL -> {
TabletopLayout()
}
foldingFeature?.state == FoldingFeature.State.FLAT -> {
ExpandedLayout()
}
else -> {
CompactLayout()
}
}
}
@Composable
fun TabletopLayout() {
Column(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f).fillMaxWidth()) {
VideoPlayer()
}
Box(Modifier.weight(0.4f).fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow)) {
PlaybackControls()
}
}
}
Rule 6: Content width constraints — never stretch text on large screens
@Composable
fun ReadableContent(content: @Composable ColumnScope.() -> Unit) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val maxContentWidth = minOf(maxWidth, 720.dp)
Box(Modifier.fillMaxSize(), Alignment.TopCenter) {
Column(
Modifier.width(maxContentWidth)
.padding(horizontal = Spacing.md)
.verticalScroll(rememberScrollState()),
content = content
)
}
}
}
@Composable
fun WindowSizeClass.contentPadding() = PaddingValues(
horizontal = when {
isExpanded -> Spacing.xl
isMedium -> Spacing.lg
else -> Spacing.md
}
)
Rule 7: Bottom sheet → side sheet on tablets
@Composable
fun AdaptiveSheet(
isVisible: Boolean,
onDismiss: () -> Unit,
windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass,
content: @Composable () -> Unit
) {
if (windowSizeClass.isCompact) {
if (isVisible) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Box(Modifier.navigationBarsPadding()) { content() }
}
}
} else {
AnimatedVisibility(
visible = isVisible,
enter = slideInHorizontally(initialOffsetX = { it }),
exit = slideOutHorizontally(targetOffsetX = { it })
) {
Box(
Modifier.fillMaxHeight().width(360.dp)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.align(Alignment.CenterEnd)
) {
Column {
Row(Modifier.fillMaxWidth().padding(Spacing.md),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text("Filter", style = MaterialTheme.typography.titleMedium)
IconButton(onDismiss) { Icon(Icons.Default.Close, "Close") }
}
content()
}
}
}
}
}
Rule 8: Adaptive dialog — constrained on large screens
@Composable
fun AppDialog(
title: String, body: String, onConfirm: () -> Unit, onDismiss: () -> Unit,
confirmText: String = "Confirm", dismissText: String = "Cancel"
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(body, style = MaterialTheme.typography.bodyMedium) },
modifier = Modifier.widthIn(max = 400.dp),
confirmButton = { TextButton(onConfirm) { Text(confirmText) } },
dismissButton = { TextButton(onDismiss) { Text(dismissText) } }
)
}
@Composable
fun AdaptiveFormDialog(
isVisible: Boolean, onDismiss: () -> Unit,
windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass,
content: @Composable () -> Unit
) {
if (!isVisible) return
if (windowSizeClass.isCompact) {
Dialog(onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { content() }
}
} else {
Dialog(onDismissRequest = onDismiss) {
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.widthIn(max = 560.dp)) { content() }
}
}
}
Rule 9: SupportingPaneScaffold — three-pane layouts
@Composable
fun ThreePaneScreen() {
val navigator = rememberSupportingPaneScaffoldNavigator<String>()
SupportingPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
mainPane = {
AnimatedPane {
MainContent(
onShowDetails = { navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, it) }
)
}
},
supportingPane = {
AnimatedPane {
SupportingContent(navigator.currentDestination?.content)
}
},
extraPane = {
AnimatedPane {
ExtraContent()
}
}
)
}
Rule 10: Predictive Back — gesture-driven navigation preview
@Composable
fun HomeScreen(navController: NavController) {
val scale by animateFloatAsState(
targetValue = 1f,
animationSpec = spring(stiffness = Spring.StiffnessMedium),
label = "screenScale"
)
Box(Modifier.graphicsLayer { scaleX = scale; scaleY = scale }) {
ScreenContent()
}
}
Common Mistakes
❌ Hardcoded BottomNavigation — use NavigationSuiteScaffold
❌ Single-column layout on tablet — use ListDetailPaneScaffold for list+detail
❌ LazyColumn with no grid alternative on large screen — use GridCells.Adaptive
❌ Full-width text — cap at 720.dp with widthIn(max = 720.dp)
❌ Same ModalBottomSheet on tablet — switch to side sheet on medium+
❌ Fixed-size dialog — use widthIn(max = 400.dp) always
❌ Ignoring FoldingFeature — check windowPosture.hingeList for foldables
❌ No BackHandler in list-detail — handle back for phone navigation
Deep-dive references
references/three-pane-layouts.md — complex SupportingPaneScaffold patterns
references/adaptive-testing.md — resizable emulator testing guide
references/chromebook-support.md — keyboard/mouse/trackpad support