Material 3 and Theming in Compose
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Material 3 is the design system Compose’s material3 library implements, and MaterialTheme is the single
composable that supplies it: a color scheme, a type scale and a shape scale, made available to every composable
below it through CompositionLocal`s (State, Recomposition
and Side Effects in Compose). This page covers theming and the core Material 3 components as of Compose BOM
2026.09.00 (`androidx.compose.material3:material3 1.4.0), building on
Jetpack Compose Fundamentals and
Compose Layouts, Modifiers and Lazy Lists. The
running example is TrailMate's TrailMateTheme and the Scaffold-based shell that hosts TrailListScreen,
TrailDetailScreen, SettingsScreen and PhotoCaptureScreen.
This page does not re-teach remember, state hoisting or the effect APIs a themed composable still relies on — see State, Recomposition and Side Effects in Compose for
those — and it does not cover NavHost/routes, which
Navigation in Compose handles once TrailMateTheme and Scaffold
are in place. Readers coming from the classic Views theming model
(Views, XML Layouts, View Binding and Material Components) should read
Migrating an XML Theme to Compose below, which links to the full phased migration on
Migrating from Views to Compose.
MaterialTheme: Color Scheme, Typography and Shapes
MaterialTheme is a composable function taking three inputs — colorScheme, typography and shapes — and
exposing each back out through MaterialTheme.colorScheme, MaterialTheme.typography and MaterialTheme.shapes
for every composable nested inside it to read:
// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/TrailMateTheme.kt
@Composable
fun TrailMateThemeSkeleton(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = lightColorScheme(primary = Color(0xFF2E6B4F)),
typography = Typography(),
shapes = Shapes(),
content = content,
)
}
A ColorScheme is a fixed set of named color roles — primary, onPrimary, primaryContainer,
secondary, tertiary, surface, surfaceVariant, error, and their on*/*Container pairs — rather than
a raw palette; every Material 3 component reads its colors from these roles, never from a literal Color value,
so recoloring the whole app means constructing a different ColorScheme and nothing else. lightColorScheme()
and darkColorScheme() build one from a handful of seed colors, defaulting every role Google’s Material color
system derives from them; TrailMate overrides only the roles that matter to its brand (primary,
primaryContainer) and lets the rest default:
// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/Color.kt
val TrailMateLightColors = lightColorScheme(
primary = Color(0xFF2E6B4F),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFB3F0CE),
onPrimaryContainer = Color(0xFF002112),
secondary = Color(0xFF4F6354),
error = Color(0xFFBA1A1A),
)
val TrailMateDarkColors = darkColorScheme(
primary = Color(0xFF97D5AF),
onPrimary = Color(0xFF00391F),
primaryContainer = Color(0xFF15522F),
onPrimaryContainer = Color(0xFFB3F0CE),
secondary = Color(0xFFB6CCB9),
error = Color(0xFFFFB4AB),
)
Generating a complete, accessible role set by hand for both light and dark from a single brand color is tedious
and error-prone; TrailMate’s actual values in the sample above were generated with the Material Theme Builder
(linked in References) and pasted in as a Color.kt file, rather than picked one role at a time.
Typography is Material 3’s type scale: fifteen named TextStyle`s grouped into five roles — `displayLarge/Medium/Small, headlineLarge/Medium/Small, titleLarge/Medium/Small,
bodyLarge/Medium/Small and labelLarge/Medium/Small — read as MaterialTheme.typography.titleLarge,
and so on. Overriding one or more styles keeps the rest at Material 3’s defaults:
// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/Type.kt
val TrailMateTypography = Typography(
titleLarge = TextStyle(
fontFamily = TrailMateFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
),
bodyMedium = TextStyle(
fontFamily = TrailMateFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
),
)
Shapes is the third subsystem: five corner-shape sizes — extraSmall, small, medium, large,
extraLarge — each component maps itself onto by default (a Card uses medium, a ModalBottomSheet uses
extraLarge for its top corners, and so on):
// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/Shape.kt
val TrailMateShapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(28.dp),
)
Any component also accepts its own shape/colors/textStyle parameter to deviate from the theme locally — overriding MaterialTheme.shapes.medium on one specific Card — but reading from MaterialTheme by default is
what keeps every screen visually consistent without threading colors and text styles through every composable’s
parameter list. See
Android Developers — Material Design 3
in Compose and
Android Developers — androidx.compose.material3 reference.
TrailMateTheme: Light, Dark and Dynamic Color
TrailMate wraps MaterialTheme in its own TrailMateTheme composable, the single entry point every screen
calls, which decides which ColorScheme to pass based on the system’s dark-theme setting and, on Android 12+,
the user’s wallpaper-derived dynamic color:
// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/TrailMateTheme.kt
@Composable
fun TrailMateTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> TrailMateDarkColors
else -> TrailMateLightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = TrailMateTypography,
shapes = TrailMateShapes,
content = content,
)
}
isSystemInDarkTheme() reads the platform’s current day/night configuration reactively — the composable
recomposes automatically if the user flips the system setting while TrailMate is open — and is the Compose
equivalent of the Views stack’s AppCompatDelegate.setDefaultNightMode /
Configuration.uiMode check covered on
Views, XML Layouts, View Binding and Material Components.
dynamicLightColorScheme(context)/dynamicDarkColorScheme(context), available since API 31 (Android 12), derive
a full ColorScheme from the device wallpaper’s extracted colors instead of TrailMate’s own brand palette — Material You personalization — and are guarded behind the Build.VERSION.SDK_INT check because they simply do
not exist on older platforms; TrailMate exposes dynamicColor as a parameter (rather than hardcoding it to
true) so a SettingsScreen toggle and @Preview functions can force it off and see the brand palette
consistently. TrailNavHost (Navigation in Compose) is wrapped in
exactly one TrailMateTheme call, at the top of the composable tree, in TrailMateApp:
// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
@Composable
fun TrailMateApp(userPreferences: UserPreferences) {
TrailMateTheme(dynamicColor = userPreferences.dynamicColorEnabled) {
Surface(color = MaterialTheme.colorScheme.background) {
TrailNavHost()
}
}
}
The outer Surface reads MaterialTheme.colorScheme.background explicitly because Surface is what actually
paints a solid background behind the content; MaterialTheme itself only provides the color scheme through
`CompositionLocal`s, it never paints anything on its own. See
Android Developers — Dynamic colors and
Android Developers — Dark theme.
Fonts
FontFamily groups one or more Font`s — typically several weights of the same typeface — into the value a
`TextStyle.fontFamily expects. TrailMate bundles a variable font as a resource rather than depending on
Google’s Downloadable Fonts service, so its type scale renders identically offline and does not depend on a
network fetch completing before the first frame:
// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/Type.kt
val TrailMateFontFamily = FontFamily(
Font(R.font.manrope_regular, FontWeight.Normal),
Font(R.font.manrope_medium, FontWeight.Medium),
Font(R.font.manrope_semibold, FontWeight.SemiBold),
)
FontFamily.Default (the platform’s system sans-serif) is a reasonable starting point and what
TrailMateThemeSkeleton above implicitly uses by leaving fontFamily unset; a custom FontFamily like
TrailMate’s is what turns a generic Material 3 look into an app’s own visual identity, changed in exactly one
place (Type.kt) rather than on every Text call site. A downloadable font — fetched from Google Fonts at
install or first run via androidx.compose.ui.text.googlefonts.Font — is the alternative when bundling every
weight as a resource would bloat the APK; TrailMate does not use it, trading a slightly larger APK for
guaranteed offline availability. See
Android Developers — Fonts in Compose.
Scaffold
Scaffold is Material 3’s top-level screen layout: a single composable that reserves the standard slots — a top bar, a bottom bar, a floating action button, a snackbar host and the main content — and measures them so
they never overlap, including correctly consuming window insets for each slot. Every TrailMate screen below the
navigation host is built on one:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrailListScreen(
onTrailClick: (String) -> Unit,
onAddTrailClick: () -> Unit,
viewModel: TrailListViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text("Trails") },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
FloatingActionButton(onClick = onAddTrailClick) {
Icon(Icons.Filled.Add, contentDescription = "Record a new trail")
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
TrailListContent(
uiState = uiState,
onTrailClick = onTrailClick,
modifier = Modifier.padding(innerPadding),
)
}
}
The lambda Scaffold passes its content is the one place innerPadding is easy to forget: it carries exactly
the insets the top bar, bottom bar and system bars already consume, and applying it to the content’s outermost
modifier is what stops the list from starting underneath the top app bar.
Top-app-bar scroll behaviors. TopAppBarDefaults exposes several TopAppBarScrollBehavior factories that
change how the bar reacts as the content below it scrolls, wired to Scaffold’s content through
`Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) exactly as in TrailListScreen above:
| Factory | Behavior |
|---|---|
|
The bar never moves or changes size; only its container color can animate as content scrolls underneath. The
default when no |
|
The bar scrolls off-screen as the user scrolls down and reappears immediately on the first upward scroll — TrailMate’s choice for |
|
For a |
|
Effectively pins the bar while still participating in the nested-scroll chain — useful when a screen wants a fixed bar but still needs to consume/react to scroll deltas for another reason. |
Bottom app bar. BottomAppBar occupies Scaffold’s `bottomBar slot and, like TopAppBar, can carry its
own actions and an optional docked floatingActionButton cutout:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
Scaffold(
bottomBar = {
BottomAppBar(
actions = {
IconButton(onClick = onFilterClick) {
Icon(Icons.Filled.FilterList, contentDescription = "Filter trails")
}
IconButton(onClick = onSortClick) {
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort trails")
}
},
floatingActionButton = {
FloatingActionButton(onClick = onAddTrailClick, elevation = BottomAppBarDefaults.bottomAppBarFabElevation()) {
Icon(Icons.Filled.Add, contentDescription = "Record a new trail")
}
},
)
},
) { innerPadding -> /* ... */ }
TrailMate reserves BottomAppBar for TrailListScreen’s filter/sort actions and uses `NavigationSuiteScaffold — which shows a NavigationBar on phones (Navigation components: NavigationBar and NavigationRail) — for the app’s top-level destinations, rather than combining both bottom bars on the same screen. SnackbarHost paired with a remembered SnackbarHostState
is Scaffold’s slot for transient messages — calling `snackbarHostState.showSnackbar("Trail saved") from a
coroutine (typically inside a LaunchedEffect reacting to a one-shot event from the ViewModel, or from
rememberCoroutineScope()) is the Compose replacement for the Views stack’s Snackbar.make(view, …).show().
See
Android Developers — Scaffold and
Android Developers — Material components in
Compose.
Core Components
Material 3’s component library covers the controls almost every screen needs; TrailMate combines the ones below
across TrailListScreen, TrailDetailScreen, SettingsScreen and PhotoCaptureScreen.
Buttons. Five button variants share the same parameter shape (onClick, enabled, content) and differ
only in emphasis: Button (filled, highest emphasis), FilledTonalButton, ElevatedButton, OutlinedButton
and TextButton (lowest emphasis). TrailMate uses Button for "Start hike", OutlinedButton for "Cancel", and
TextButton inside dialogs:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
Row {
OutlinedButton(onClick = onCancelClick) { Text("Cancel") }
Spacer(Modifier.width(8.dp))
Button(onClick = onStartHikeClick, enabled = uiState.canStartHike) { Text("Start hike") }
}
Cards. Card, ElevatedCard and OutlinedCard group related content behind a MaterialTheme.shapes-shaped
container; TrailMate’s TrailListItem is a clickable Card (the onClick overload) wrapping a thumbnail and
two lines of text:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListItem.kt
@Composable
fun TrailListItem(trail: Trail, onClick: () -> Unit, onFavoriteClick: () -> Unit) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
AsyncImage(model = trail.thumbnailUrl, contentDescription = null, modifier = Modifier.size(56.dp))
Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
Text(trail.name, style = MaterialTheme.typography.titleMedium)
Text("${trail.lengthKm} km", style = MaterialTheme.typography.bodyMedium)
}
IconToggleButton(checked = trail.isFavorite, onCheckedChange = { onFavoriteClick() }) {
Icon(
if (trail.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = "Toggle favorite",
)
}
}
}
}
Chips. AssistChip, FilterChip, InputChip and SuggestionChip cover distinct interaction patterns;
TrailListScreen’s difficulty filter uses `FilterChip, whose selected boolean drives its own visual state:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailFilterRow.kt
FilterChip(
selected = uiState.selectedDifficulty == Difficulty.EASY,
onClick = { onDifficultySelected(Difficulty.EASY) },
label = { Text("Easy") },
)
Dialogs. AlertDialog takes onDismissRequest, a confirmButton/dismissButton pair of TextButton`s, and
`title/text content slots; TrailMate confirms a destructive action — deleting a saved trail — with one:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
if (uiState.showDeleteConfirmation) {
AlertDialog(
onDismissRequest = onDismissDeleteConfirmation,
title = { Text("Delete this trail?") },
text = { Text("This cannot be undone.") },
confirmButton = { TextButton(onClick = onConfirmDelete) { Text("Delete") } },
dismissButton = { TextButton(onClick = onDismissDeleteConfirmation) { Text("Cancel") } },
)
}
ModalBottomSheet. A ModalBottomSheet slides up over the current screen, dimming it, and is driven by a
rememberModalBottomSheetState(); TrailMate uses one for a trail’s share/export options instead of a full
dialog, because the option list can grow past a comfortable dialog height:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailShareSheet.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrailShareSheet(onDismiss: () -> Unit, onExportGpx: () -> Unit, onShareImage: () -> Unit) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(modifier = Modifier.padding(bottom = 32.dp)) {
ListItem(
headlineContent = { Text("Export as GPX") },
leadingContent = { Icon(Icons.Filled.Route, contentDescription = null) },
modifier = Modifier.clickable(onClick = onExportGpx),
)
ListItem(
headlineContent = { Text("Share as image") },
leadingContent = { Icon(Icons.Filled.Share, contentDescription = null) },
modifier = Modifier.clickable(onClick = onShareImage),
)
}
}
}
Navigation components: NavigationBar and NavigationRail. NavigationBar renders
Material 3’s bottom navigation across a fixed set of NavigationBarItem`s; `NavigationRail is its
landscape/large-screen counterpart, docked to the leading edge instead of the bottom. Choosing between the two by
window size class by hand looks like this:
// app/src/main/kotlin/com/example/trailmate/TrailMateNavigationBar.kt (manual version, for illustration)
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
NavigationBar {
topLevelDestinations.forEach { destination ->
NavigationBarItem(
selected = destination == currentDestination,
onClick = { onNavigate(destination) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
label = { Text(destination.label) },
)
}
}
} else {
NavigationRail {
topLevelDestinations.forEach { destination ->
NavigationRailItem(
selected = destination == currentDestination,
onClick = { onNavigate(destination) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
label = { Text(destination.label) },
)
}
}
}
TrailMate does not ship that manual switch. It uses NavigationSuiteScaffold from
androidx.compose.material3:material3-adaptive-navigation-suite, which makes exactly this decision for it — NavigationBar on compact widths, NavigationRail on wider windows (a navigation drawer is available through
its layoutType parameter) — from the same currentWindowAdaptiveInfo(), as shown on
Navigation in Compose (Navigation Compose and Navigation 3):
// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
NavigationSuiteScaffold(
navigationSuiteItems = {
topLevelDestinations.forEach { destination ->
item(
selected = destination == currentDestination,
onClick = { onNavigate(destination) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
label = { Text(destination.label) },
)
}
},
) {
TrailMateNavContent() // the NavHost or NavDisplay
}
TextField. TextField (filled) and OutlinedTextField share the same parameter shape — value/onValueChange, label, supportingText, isError, keyboardOptions — and differ only in visual
style; TrailMate’s custom-hike form uses OutlinedTextField with input validation surfaced through
isError/supportingText:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/CustomHikeForm.kt
OutlinedTextField(
value = uiState.lengthKmInput,
onValueChange = onLengthKmChange,
label = { Text("Length (km)") },
isError = uiState.lengthKmError != null,
supportingText = { uiState.lengthKmError?.let { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
)
Switch and Slider. Switch(checked, onCheckedChange) is TrailMate’s SettingsScreen control for
dynamicColorEnabled and similar booleans; Slider(value, onValueChange, valueRange) drives a continuous value
such as the minimum trail difficulty shown in the list filter:
// feature/settings/src/main/kotlin/com/example/trailmate/feature/settings/SettingsScreen.kt
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Use wallpaper colors", modifier = Modifier.weight(1f))
Switch(checked = uiState.dynamicColorEnabled, onCheckedChange = onDynamicColorToggle)
}
Slider(
value = uiState.minDifficulty,
onValueChange = onMinDifficultyChange,
valueRange = 0f..5f,
)
SearchBar. Material 3’s SearchBar combines a text field with an expandable results surface; TrailMate’s
trail search expands over TrailListScreen rather than navigating to a separate screen:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailSearchBar.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrailSearchBar(uiState: TrailSearchUiState, onQueryChange: (String) -> Unit, onResultClick: (String) -> Unit) {
// Expanded state is UI-only, so it lives here; rememberSaveable keeps it across rotation.
var expanded by rememberSaveable { mutableStateOf(false) }
SearchBar(
inputField = {
SearchBarDefaults.InputField(
query = uiState.query,
onQueryChange = onQueryChange,
onSearch = {},
expanded = expanded,
onExpandedChange = { expanded = it },
placeholder = { Text("Search trails") },
)
},
expanded = expanded,
onExpandedChange = { expanded = it },
) {
uiState.results.forEach { result ->
ListItem(
headlineContent = { Text(result.name) },
modifier = Modifier.clickable {
expanded = false // collapse before navigating to the result
onResultClick(result.id)
},
)
}
}
}
DatePicker, briefly. DatePickerDialog wrapping a DatePicker (state from rememberDatePickerState())
is Material 3’s calendar-grid date picker; TrailMate uses it once, for filtering the trail list by a "hiked
since" date, and otherwise favors SettingsScreen controls that do not need a full calendar surface. See
Android Developers — Material components in
Compose and
Android Developers — androidx.compose.material3 reference for the complete component catalog, including Snackbar,
ProgressIndicator, Badge, Tooltip and SegmentedButton, not detailed individually above.
Material 3 Expressive and MotionScheme
Material 3 Expressive is Google’s newer, more animated and more colorful evolution of Material 3 — richer shape morphing, a wider color palette, and a motion system built around springs rather than fixed-duration
easing curves. In compose-material3 1.4.0 (this section’s baseline), its APIs — MaterialExpressiveTheme,
expressive component variants, and MotionScheme — remain gated behind
@ExperimentalMaterial3ExpressiveApi and are not recommended for TrailMate’s production screens yet; the
release notes for compose-material3 1.5.0's alpha series describe these APIs graduating to stable there.
MotionScheme is the piece worth knowing ahead of that graduation: it centralizes an app’s spring-based motion
tokens (defaultSpatialSpec, fastSpatialSpec, defaultEffectsSpec) the same way ColorScheme centralizes
colors, so that once it stabilizes, swapping TrailMate’s whole app between a calmer and a more expressive motion
feel becomes a one-line change to TrailMateTheme rather than a search-and-replace across every
animateFloatAsState/AnimatedVisibility call
(Animation and Gestures in Compose covers those APIs on
their own). Until then, TrailMate tracks the stable compose-material3 release and revisits Expressive once its
APIs are no longer experimental. See
Android Developers — androidx.compose.material3 release notes.
Custom Design Systems
Building a design system that departs from Material 3’s roles and components entirely — a distinct
ColorScheme-shaped API of an app’s own, custom component composables instead of Button/Card — is possible
but rarely worth it for an app the size of TrailMate: it means re-implementing accessibility behavior (touch
target sizes, contrast, TalkBack semantics) that material3 already gets right, and losing automatic updates
whenever Material 3 itself improves. Cheaper, well-supported alternatives, in the order TrailMate would reach
for them, are: overriding MaterialTheme’s `colorScheme/typography/shapes (MaterialTheme: Color Scheme, Typography and Shapes) to reach a distinct brand look while keeping every existing component; wrapping a
Material 3 component behind an app-specific composable name (TrailPrimaryButton calling Button internally)
so the wrapping call site can add TrailMate-specific defaults without touching the component’s own
implementation; and, only for a component Material 3 genuinely does not offer (a route elevation-profile chart,
in TrailMate’s case), writing a bespoke composable from Compose’s foundation layer while still reading colors
and text styles from MaterialTheme. A true custom design system remains the right call for a large
multi-brand product or a company-wide component library, not for a single consumer app. See
Android Developers — Building a custom
design system in Compose.
Migrating an XML Theme to Compose
A View-based screen theming color, typography and shapes through res/values/themes.xml and MDC’s
Theme.Material3. parent themes (Views, XML Layouts, View Binding and
Material Components) maps onto TrailMateTheme role by role: an XML colorPrimary attribute becomes
ColorScheme.primary, a TextAppearance.Material3.TitleLarge style becomes Typography.titleLarge, and a
shapeAppearanceCornerExtraLarge becomes Shapes.extraLarge. TrailMate’s own migration — introducing
TrailMateTheme to coexist with the still-live Theme.Material3.DayNight XML theme during the transition, and
wrapping remaining XML screens in a ComposeView themed to match — is covered end to end, with before/after
code for that step and every step after it, on
Migrating from Views to Compose; this page only maps the
theme *attributes themselves, not the wider phased plan. See
Android Developers — Migrate an XML theme to Compose and
Android Developers — Migrate views to Compose.
Icons
androidx.compose.material:material-icons-core ships Icons.Filled/Icons.Outlined/Icons.Rounded/
Icons.Sharp/Icons.TwoTone for the common actions (Add, Share, Favorite); the much larger long-tail set
lives in the separate material-icons-extended artifact (Icons.Filled.Route, Icons.Filled.FilterList used
above), which is worth excluding from a release build with R8’s resource shrinking if only a handful of its
icons are actually referenced, since the full extended set is large. A mirrored-for-RTL icon such as Sort — one whose visual direction should flip in a right-to-left layout — lives instead under
Icons.AutoMirrored.Filled.Sort, as used in the BottomAppBar example in Scaffold above; reaching for the
plain, non-AutoMirrored variant of an icon that has an AutoMirrored counterpart is a common mistake that
silently breaks RTL layouts. Icon(imageVector, contentDescription) is the composable that actually draws one,
tinted by LocalContentColor (typically MaterialTheme.colorScheme.onSurface or a component’s own foreground
color) unless a tint is passed explicitly; a purely decorative icon that repeats an adjacent text label passes
contentDescription = null so screen readers skip it, exactly as `TrailListItem’s icons above only describe
themselves where they carry meaning on their own. See
Android Developers — Material icons
in Compose.