Material Design 3 & Adaptive Layouts Explained

UI › Material & Nav

Any interviewer hiring for a modern Android team assumes you can build a screen that looks like it belongs on Material 3, and that survives having its window resized: a foldable unfolding mid-session, a window shrinking into split-screen, a tablet turned to landscape. This topic is really two skills stacked together, the M3 theming system that makes a screen on-brand, and the window-size-aware layout system that makes it adapt. Start with theming.

Every M3-styled screen sits inside a MaterialTheme composable, and it exposes exactly three things you read back everywhere else in your UI.

MaterialTheme(
    colorScheme = colorScheme,
    typography = typography,
    shapes = shapes
) {
    // your app content
}

At any call site you pull them back out as MaterialTheme.colorScheme, MaterialTheme.typography, and MaterialTheme.shapes, for example MaterialTheme.colorScheme.primary or MaterialTheme.typography.headlineSmall. That's the whole theming contract an interviewer checks first: can you name the three parameters, and do you know to read them through MaterialTheme.X rather than hardcoding a color or text style inline.

The values inside colorScheme don't have to come from a fixed, designer-authored palette. **Dynamic color**, Material You, generates a ColorScheme from the user's wallpaper instead.

val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
    else dynamicLightColorScheme(LocalContext.current)
} else {
    if (darkTheme) DarkColorScheme else LightColorScheme
}
MaterialTheme(colorScheme = colorScheme) { content() }

The catch every interviewer expects you to know: dynamicLightColorScheme and dynamicDarkColorScheme require **Android 12, API 31**, the release where wallpaper-derived color extraction landed on the platform. If your app supports anything older, you cannot call those functions unconditionally, you branch on Build.VERSION.SDK_INT and supply a hand-authored **static** ColorScheme below API 31. There is no partial or emulated dynamic color on older versions, only the static fallback.

M3 also changes what *elevation* visually means. Material 2 relied almost entirely on drop shadows. M3 primarily uses a **tonal overlay**: as a surface's elevation increases, it's tinted more toward the primary color, which reads clearly even in dark theme where shadows barely show. Surface exposes the two concerns separately:

Surface(
    tonalElevation = 3.dp,   // tints toward primary
    shadowElevation = 0.dp,  // optional shadow, independent
) {
    Text("Elevated in M3")
}

You can have tonal elevation with no shadow, a shadow with no tonal tint, or both. Knowing they're independent knobs, not one "elevation" number like M2, is the detail that separates "I've used Compose" from "I understand M3's design language".

Every color role in the M3 scheme comes with a paired "on" role for content drawn on top of it, and the pairing matters for accessible contrast:

Box(modifier = Modifier.background(MaterialTheme.colorScheme.primary)) {
    Text(
        text = "Label",
        color = MaterialTheme.colorScheme.onPrimary   // guaranteed contrast
    )
}

primaryContainer is a distinct, lower-emphasis surface, with its own onPrimaryContainer pair, not the same thing as primary at a lighter tone. And surfaceTint is a different concept entirely, it's the color used to compute the tonal elevation overlay from the previous chunk, not a role you'd apply to text. Mixing these up in an interview is a classic tell of memorized-but-not-understood theming.

MaterialTheme has a third parameter you haven't used yet: shapes. It holds a set of named corner styles, from extraSmall through extraLarge, and components pick the appropriate one automatically.

MaterialTheme(
    shapes = Shapes(
        extraSmall = RoundedCornerShape(4.dp),
        small      = RoundedCornerShape(8.dp),
        medium     = RoundedCornerShape(12.dp),
        large      = RoundedCornerShape(16.dp),
        extraLarge = RoundedCornerShape(28.dp)
    )
) {
    Card { Text("Rounded") }
}

A Card or Button doesn't get its rounded corners from a value you set on the component itself, it reads one of these named shapes from the theme. Change shapes once at the theme root and every component using that role updates together, the same pattern as colorScheme and typography. Motion, spacing, and elevation are separate systems entirely, shapes is only about corner treatment.

With colorScheme, typography, and shapes as the theming system you read from, the components you build on top of it follow a common structural pattern: Scaffold. It's the layout skeleton for a typical M3 screen, and it takes named slots, topBar, bottomBar, floatingActionButton, and a content lambda, so a TopAppBar, a NavigationBar, a FAB, and Cards inside the content all line up correctly against each other without you computing offsets by hand.

Scaffold(
    topBar = { TopAppBar(title = { Text("My App") }) },
    bottomBar = { NavigationBar { /* NavigationBarItem per destination */ } },
    floatingActionButton = { FloatingActionButton(onClick = {}) { Icon(Icons.Default.Add, null) } }
) { innerPadding ->
    LazyColumn(contentPadding = innerPadding) {
        items(list) { Card { Text(it) } }
    }
}

That innerPadding parameter is not optional decoration, it reports exactly how much space the bars and system insets consume, and you must apply it, either via Modifier.padding or by handing it to a scrollable as contentPadding like above. Ignore it and your first rows render hidden underneath the top bar.

You've already used a TopAppBar inside Scaffold's topBar slot. Making it collapse as the user scrolls is a common Android-interview live-coding ask, and it's driven by a TopAppBarScrollBehavior:

val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()

Scaffold(
    modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
    topBar = {
        TopAppBar(
            title = { Text("My App") },
            scrollBehavior = scrollBehavior
        )
    }
) { innerPadding ->
    LazyColumn(contentPadding = innerPadding) { /* items */ }
}

Two pieces have to connect: the bar needs the scrollBehavior to know how to animate itself, and Scaffold needs Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) so scroll deltas from the LazyColumn actually reach the bar in the first place. Forget the nestedScroll wiring and the bar just sits there, static, no matter what scroll behavior you pass it.

Now the adaptive half of this topic. A screen might be gorgeous on a phone and cramped or wasteful on a tablet, so M3 gives you **window size classes**, dp buckets describing how much horizontal space your app actually has.

The width breakpoints:

- Compact: width < 600dp - Medium: 600dp to < 840dp - Expanded: 840dp to < 1200dp - Large: 1200dp to < 1600dp - Extra-large: 1600dp and up

A phone in portrait is almost always Compact. A tablet in portrait or a large unfolded foldable typically lands in Medium. Landscape tablets and desktop-class windows push into Expanded, and beyond that into Large and Extra-large. These are just named ranges, the useful part is what you do once you know which one you're in, which is next.

You don't hardcode those thresholds yourself, you read the current size class through currentWindowAdaptiveInfo(), which lives in the androidx.compose.material3.adaptive family of artifacts, alongside the adaptive scaffolds you'll meet shortly.

// build.gradle.kts
implementation("androidx.compose.material3.adaptive:adaptive:1.1.0")
implementation("androidx.compose.material3.adaptive:adaptive-layout:1.1.0")
implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.1.0")

val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
when (windowSizeClass.windowWidthSizeClass) {
    WindowWidthSizeClass.COMPACT  -> { /* phone portrait */ }
    WindowWidthSizeClass.MEDIUM   -> { /* tablet portrait */ }
    WindowWidthSizeClass.EXPANDED -> { /* tablet landscape */ }
}

You can also query a specific breakpoint directly with isWidthAtLeastBreakpoint or isHeightAtLeastBreakpoint instead of switching on the whole enum, useful when you only care about one threshold. Either way, this call belongs in androidx.compose.material3.adaptive, not the core material3 package, and it's unrelated to the older androidx.window.embedding activity-embedding library some interviewers will try to conflate it with.

There's one more thing about window size classes that trips people up: they describe the app's **available window**, not the physical device. A tablet doesn't have one fixed size class, whatever currentWindowAdaptiveInfo() reports depends entirely on how much space your app currently has.

// Size class reflects the app's available window, not the physical screen
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
// Updates automatically on split-screen, multi-window, and foldable posture changes

Drag the same tablet app into split-screen and its Expanded window can become Compact in an instant, nothing about the device changed, only the space the app was given. Fold or unfold a foldable and the same thing happens. That means a size class check has to be reactive, read fresh through currentWindowAdaptiveInfo(), never decided once from a device model or a screen-diagonal check at startup.

Two scaffolds build directly on size classes so you stop branching on breakpoints by hand everywhere in your codebase.

NavigationSuiteScaffold swaps your navigation chrome automatically, a bottom NavigationBar at compact width, a NavigationRail (or a drawer) as the window widens.

NavigationSuiteScaffold(
    navigationSuiteItems = {
        items.forEach { item ->
            item(
                icon = { Icon(item.icon, contentDescription = item.label) },
                label = { Text(item.label) },
                selected = currentDestination == item.route,
                onClick = { navController.navigate(item.route) }
            )
        }
    }
) { /* screen content, bar/rail/drawer chosen automatically */ }

ListDetailPaneScaffold, used via NavigableListDetailPaneScaffold, handles the classic list-detail pattern: list and detail side by side at expanded width, collapsing to one navigable pane below that.

val navigator = rememberListDetailPaneScaffoldNavigator<Nothing>()
NavigableListDetailPaneScaffold(
    navigator = navigator,
    listPane = { AnimatedPane { ItemList(onItemSelected = {
        navigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
    }) } },
    detailPane = { AnimatedPane { ItemDetail() } }
)

That's the whole topic in one line: MaterialTheme gives you color, type, and shape as a system to read from rather than hardcode, elevation is tonal, not just shadow, and adaptive layout means computing from the window size class and reaching for a scaffold like NavigationSuiteScaffold or ListDetailPaneScaffold instead of hand-rolling breakpoint branches on every screen. Say that in an interview, with one code example for each half, and you've covered what they're checking for.

Back to Material Design 3 & Adaptive Layouts