Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory. Co-authored-by: Junie <junie@jetbrains.com>
8.7 KiB
Material 3 Theming & Components
TL;DR Defaults
| Concern | Default |
|---|---|
| Theme entry point | MaterialTheme(colorScheme, typography, shapes) wrapping app content |
| Dynamic color | Enable on Android 12+; fall back to brand ColorScheme on older APIs |
| Dark/light | Follow system via isSystemInDarkTheme(); expose user override if needed |
| Color pairing | Always pair primary/onPrimary, surface/onSurface, *Container/on*Container |
| Typography | Use default M3 type scale; override only specific slots for branding |
| Shapes | Use default M3 shape scale; override per-slot (small, medium, large) |
| Scaffold | Use Scaffold for screens with app bars, FAB, snackbar, or bottom bar |
| Navigation | NavigationSuiteScaffold auto-switches bar/rail by window size |
| Snackbar | SnackbarHostState in Route; show via Effect from ViewModel |
| Bottom sheet | ModalBottomSheet with SheetState; control via show()/hide() |
| Dialog | AlertDialog for simple confirm/dismiss; custom Dialog for complex content |
| Adaptive layout | Derive window size class once at app level; pass down as state |
Theming Baseline
Theme Setup
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
Key Rules
- Define
LightColorSchemeandDarkColorSchemeusinglightColorScheme()/darkColorScheme(). - Generate brand colors via Material Theme Builder for guaranteed tonal palettes.
- Dynamic color is Android-only; CMP projects fall back to brand schemes on non-Android targets.
Color Roles and Dark/Light
Role Pairing Rules
| Container | Content on it |
|---|---|
primary |
onPrimary |
primaryContainer |
onPrimaryContainer |
secondary |
onSecondary |
secondaryContainer |
onSecondaryContainer |
tertiary |
onTertiary |
tertiaryContainer |
onTertiaryContainer |
surface |
onSurface |
surfaceVariant |
onSurfaceVariant |
error |
onError |
errorContainer |
onErrorContainer |
Accessibility Guardrails
- Always use the correct
on*color for text/icons on a container. - Do not mix unrelated pairs (e.g.,
tertiaryContainerbackground withprimaryContainertext). - M3 tonal palettes guarantee 3:1+ contrast when paired correctly.
Do / Don't
| Do | Don't |
|---|---|
containerColor = primary, contentColor = onPrimary |
containerColor = primary, contentColor = tertiaryContainer |
Access colors via MaterialTheme.colorScheme.* |
Hardcode hex colors in components |
| Test both light and dark themes | Assume light-only usage |
Typography and Shapes
Typography
M3 defines 15 text styles across 5 categories:
| Category | Sizes |
|---|---|
| Display | displayLarge, displayMedium, displaySmall |
| Headline | headlineLarge, headlineMedium, headlineSmall |
| Title | titleLarge, titleMedium, titleSmall |
| Body | bodyLarge, bodyMedium, bodySmall |
| Label | labelLarge, labelMedium, labelSmall |
Default: Use M3 defaults. Override individual slots for brand fonts:
val AppTypography = Typography(
titleLarge = TextStyle(fontFamily = BrandFont, fontWeight = FontWeight.SemiBold, fontSize = 22.sp)
)
Shapes
M3 shape scale: extraSmall, small, medium, large, extraLarge.
Default: Use M3 defaults. Override only when brand requires specific corner radii:
val AppShapes = Shapes(
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp)
)
Component Decision Matrix
Scaffold
| Slot | When to use |
|---|---|
topBar |
Screen has a top app bar |
bottomBar |
Screen has bottom navigation or bottom app bar |
floatingActionButton |
Primary action needs FAB |
snackbarHost |
Screen can show snackbars |
content |
Main screen content; receives PaddingValues to apply |
Rule: Always apply innerPadding from Scaffold to content root.
Top App Bar
| Variant | Use case | Scroll / default |
|---|---|---|
TopAppBar (small) |
Simple screens, minimal actions | Default: pinnedScrollBehavior unless you need collapse |
CenterAlignedTopAppBar |
Single primary action, centered title | Same bar family as small |
MediumTopAppBar |
Moderate navigation, collapsible on scroll | exitUntilCollapsedScrollBehavior (also enterAlwaysScrollBehavior where needed) |
LargeTopAppBar |
Hero screens, prominent title, collapsible | Same scroll behavior family as medium |
Navigation
| Window size | Component |
|---|---|
| Compact (phones portrait) | NavigationBar (bottom) |
| Medium/Expanded (tablets, landscape) | NavigationRail (side) |
| Auto-switch | NavigationSuiteScaffold |
Default: Use NavigationSuiteScaffold for apps with 3-5 top-level destinations. It adapts automatically.
NavigationSuiteScaffold(
navigationSuiteItems = {
destinations.forEach { dest ->
item(
selected = currentDest == dest,
onClick = { currentDest = dest },
icon = { Icon(dest.icon, contentDescription = null) },
label = { Text(dest.label) }
)
}
}
) { DestinationContent(currentDest) }
Bottom Sheet
| Type | Use case |
|---|---|
ModalBottomSheet |
Overlays content, dismissible |
BottomSheetScaffold |
Persistent sheet integrated with screen |
State control: Use rememberModalBottomSheetState() + SheetState.show()/hide().
MVI pattern: ViewModel emits Effect.ShowSheet; Route composable calls sheetState.show() in LaunchedEffect.
Snackbar
Setup: SnackbarHostState remembered in Route; passed to Scaffold.snackbarHost.
Pattern:
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is Effect.ShowSnackbar -> {
val result = snackbarHostState.showSnackbar(effect.message, effect.actionLabel)
if (result == SnackbarResult.ActionPerformed) viewModel.onEvent(Event.SnackbarAction)
}
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { /* ... */ }
Dialog
| Type | Use case |
|---|---|
AlertDialog |
Simple title + text + confirm/dismiss buttons |
Dialog + Card |
Complex content, forms, custom layouts |
MVI pattern: Dialog visibility controlled by state.showDialog: Boolean. Confirm/dismiss dispatch events.
Adaptive Layout Defaults
Window Size Classes
| Class | Width breakpoint | Typical devices |
|---|---|---|
| Compact | < 600dp | Phones portrait |
| Medium | 600dp – 840dp | Tablets portrait, large unfolded |
| Expanded | ≥ 840dp | Tablets landscape, desktop |
Rule: Compute WindowSizeClass once at app/activity level via currentWindowAdaptiveInfo(). Pass derived layout decisions down as state.
Canonical Layouts
| Layout | Use case | Compose component |
|---|---|---|
| List-detail | Master list + detail pane | ListDetailPaneScaffold, NavigableListDetailPaneScaffold |
| Supporting pane | Main content + supplementary info | SupportingPaneScaffold, NavigableSupportingPaneScaffold |
| Feed | Grid of browsable content | LazyVerticalGrid with GridCells.Adaptive |
Default: For list-detail apps, use NavigableListDetailPaneScaffold which handles pane visibility and back navigation.
Adaptive navigation: read windowSizeClass (or related adaptive info) once at the root and pass derived flags (e.g. whether to show a top app bar) into your main screen composable.
M2 to M3 Migration Notes
| M2 | M3 |
|---|---|
Colors |
ColorScheme |
lightColors() / darkColors() |
lightColorScheme() / darkColorScheme() |
BottomNavigation |
NavigationBar |
BottomNavigationItem |
NavigationBarItem |
ModalBottomSheetLayout |
ModalBottomSheet |
ModalDrawer |
ModalNavigationDrawer |
Scaffold with scaffoldState |
Scaffold with snackbarHost slot |
BackdropScaffold |
BottomSheetScaffold or custom |
TopAppBar elevation |
TopAppBar with scrollBehavior |
Key change: M3 Scaffold no longer has drawerState. Use ModalNavigationDrawer wrapping Scaffold instead.