Skip to main content

07 - Material 3, Theming & Adaptive Layouts

Recap​

06 - State & Side Effects made screens change: state, hoisting, rememberSaveable for rotation, effects for timers and listeners. This lecture is about how the screens look and how they adapt. Material 3 gives you a theme (colors, typography, shapes) and ready-made components: dialogs, sheets, switches, sliders. Adaptive layouts make one app work in portrait, in landscape and on tablets. Both HW2 (dark/light mode, portrait/landscape layouts) and SportMap (controls move to the side in landscape) need this. At the end you'll draw your own graphics with Canvas and animate them.

By the end of this lecture you should be able to:

  • Explain Material 3 color roles, typography and shapes, and customise the generated theme.
  • Support dark mode and dynamic color, and add your own theme values.
  • Use the common Material 3 components: top app bar with menu, cards, switch, slider, segmented buttons, dialogs, bottom sheets, snackbars.
  • Adapt a layout to orientation and window size classes, and explain why locking orientation is no longer an option on large screens.
  • Draw custom graphics with Canvas/DrawScope and draw on top of other composables.
  • Animate values and visibility with the animate*AsState family and AnimatedVisibility.
  • Replace the launcher icon with an adaptive, themable icon.
Demo code

Lecture demos: native-2026-fall

Material Design 3​

Material Design 3 (Material You) is Google's design system: guidelines plus a component library. androidx.compose.material3 (already in the catalog via the BOM) implements it. Everything you used in lecture 05 (Button, Text, Scaffold, TopAppBar) reads its look from the surrounding MaterialTheme. Change the theme and the whole app follows.

The theme has three parts:

  • colorScheme: about 30 named color roles, never raw hex values in components;
  • typography: a type scale of 15 text styles;
  • shapes: corner styles from extra-small to extra-large.

The theme files​

The Empty Activity template generates ui/theme/Color.kt, Type.kt and Theme.kt. Theme.kt is the entry point:

private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)

private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)

@Composable
fun SportMapTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, // Android 12+
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 = Typography,
shapes = AppShapes,
content = content
)
}

Wrap the root of setContent { } in it once (lecture 05), and also wrap every preview so it renders with the real theme.

Color roles​

Components pick colors by role. Use roles in your own composables too:

RoleUsed forContent on it
primarythe most important actions (filled buttons, FAB)onPrimary
primaryContainerless prominent filled elements, selected itemsonPrimaryContainer
secondary / secondaryContainersupporting actions, chipsonSecondary...
tertiary / tertiaryContainercontrasting accents (e.g. highlights)onTertiary...
surfacebackgrounds of screens, sheets, cardsonSurface, onSurfaceVariant
surfaceContainerLow ... Highestlayered surfaces (cards, nav bars, dialogs)onSurface
outline, outlineVariantborders, dividers, grid lines-
error / errorContainererrorsonError...
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.medium
) {
Text("Next: X", color = MaterialTheme.colorScheme.onPrimaryContainer)
}

Pair every container color with its on... color and contrast is right in both light and dark mode. Surface, Card and buttons do this pairing automatically: they set LocalContentColor for their children.

Material Theme Builder​

Don't invent 30 colors by hand. In Material Theme Builder, pick a source (seed) color, check the generated light and dark schemes, then Export → Jetpack Compose (Theme.kt). Replace the generated Color.kt/Theme.kt with the exported ones. The exported schemes also include medium- and high-contrast variants for accessibility settings.

Dark theme and dynamic color​

  • isSystemInDarkTheme() follows the system setting. HW2 requires both modes, so check every screen with @PreviewLightDark.
  • Dynamic color (Android 12+, API 31) derives the scheme from the user's wallpaper. With minSdk 30 you still need the SDK_INT check.
  • Dynamic color is optional. When colors carry meaning, like X vs O or the speed colors of a track, call the theme with dynamicColor = false or keep those colors out of the color scheme (next section).
  • To let the user choose (System / Light / Dark), store the choice in settings (lecture 10) and pass it as darkTheme.

Typography and shapes​

The type scale has five groups in three sizes: display, headline, title, body and label, each Large/Medium/Small:

Text("1:35:47", style = MaterialTheme.typography.displaySmall)    // big numbers
Text("Checkpoint", style = MaterialTheme.typography.labelMedium) // small captions

Customise in Type.kt. Fonts go to res/font/ and are used via FontFamily(Font(R.font.my_font)):

val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
)
)

val AppShapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(12.dp), // cards
large = RoundedCornerShape(24.dp), // sheets, dialogs
)

Your own theme values​

Some values are app-specific, for example the player colors in HW2. Put them into a CompositionLocal (lecture 06) provided next to MaterialTheme:

@Immutable
data class GameColors(val x: Color, val o: Color, val winLine: Color)

val LocalGameColors = staticCompositionLocalOf {
GameColors(Color.Unspecified, Color.Unspecified, Color.Unspecified)
}

@Composable
fun GameTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val gameColors = if (darkTheme) {
GameColors(x = Color(0xFF90CAF9), o = Color(0xFFFFAB91), winLine = Color(0xFFFFF59D))
} else {
GameColors(x = Color(0xFF1565C0), o = Color(0xFFD84315), winLine = Color(0xFFF9A825))
}
SportMapTheme(darkTheme = darkTheme, dynamicColor = false) {
CompositionLocalProvider(LocalGameColors provides gameColors, content = content)
}
}

// usage
Text("X", color = LocalGameColors.current.x, style = MaterialTheme.typography.displayLarge)
XML equivalent

This replaces values/colors.xml + values-night/colors.xml + styles.xml from HW1. In Compose there are no styles. A "style" is a composable with defaults, or theme values read inside it.

Material 3 components​

Buttons and text fields were in lecture 05. These are the others SportMap and HW2 need.

Top app bar with an overflow menu​

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapTopBar(onSessions: () -> Unit, onSettings: () -> Unit, onResetMap: () -> Unit) {
var menuExpanded by remember { mutableStateOf(false) }

TopAppBar(
title = { Text("SportMap") },
actions = {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More")
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(text = { Text("Sessions") }, onClick = { menuExpanded = false; onSessions() })
DropdownMenuItem(text = { Text("Settings") }, onClick = { menuExpanded = false; onSettings() })
HorizontalDivider()
DropdownMenuItem(text = { Text("Reset map") }, onClick = { menuExpanded = false; onResetMap() })
}
}
)
}

The DropdownMenu is anchored to its parent, here the actions row. This replaces the XML res/menu + onCreateOptionsMenu from lecture 04.

Cards​

@Composable
fun SessionCard(name: String, distance: String, duration: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Card(onClick = onClick, modifier = modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(name, style = MaterialTheme.typography.titleMedium)
Text(
"$distance · $duration",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

ElevatedCard and OutlinedCard are the variants. The sessions list in lecture 18 is a LazyColumn of Cards.

Switch, slider, segmented buttons​

Settings controls follow the same value/onValueChange pattern as text fields:

@Composable
fun CompassSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.toggleable(value = checked, onValueChange = onCheckedChange, role = Role.Switch)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Show compass", modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = null) // the whole row is the touch target
}
}

@Composable
fun GpsIntervalSlider(intervalMs: Long, onIntervalChange: (Long) -> Unit) {
var seconds by remember(intervalMs) { mutableFloatStateOf(intervalMs / 1000f) }
Column(Modifier.padding(16.dp)) {
Text("GPS update every ${seconds.toInt()} s")
Slider(
value = seconds,
onValueChange = { seconds = it }, // while dragging
onValueChangeFinished = { onIntervalChange(seconds.toLong() * 1000) }, // once, on release
valueRange = 1f..10f,
steps = 8 // 1, 2, ... 10
)
}
}

@Composable
fun OrientationSelector(selected: MapOrientation, onSelect: (MapOrientation) -> Unit) {
val labels = mapOf(
MapOrientation.NORTH_UP to "North up",
MapOrientation.DIRECTION_UP to "Direction up",
MapOrientation.USER_CHOSEN to "Free",
)
val options = MapOrientation.entries
SingleChoiceSegmentedButtonRow {
options.forEachIndexed { index, option ->
SegmentedButton(
selected = option == selected,
onClick = { onSelect(option) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size)
) {
Text(labels.getValue(option))
}
}
}
}
  • In CompassSwitch the Switch gets onCheckedChange = null and the row is toggleable. The whole row is clickable, and TalkBack reads "Show compass, switch, on" once.
  • The slider keeps a local value while dragging and reports only the final one, so the settings store isn't written 50 times per drag.
  • MapOrientation and the settings repository come from lecture 10.

Dialogs​

The final project requires: "When user stops session, ask for confirmation."

@Composable
fun StopSessionDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss, // back button, tap outside
title = { Text("Stop session?") },
text = { Text("Tracking stops and the session is saved.") },
confirmButton = { TextButton(onClick = onConfirm) { Text("Stop") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Continue") } }
)
}

@Composable
fun StopButton(onStop: () -> Unit) {
var showDialog by rememberSaveable { mutableStateOf(false) }

Button(onClick = { showDialog = true }) { Text("Stop") }

if (showDialog) {
StopSessionDialog(
onConfirm = { showDialog = false; onStop() },
onDismiss = { showDialog = false }
)
}
}

A dialog in Compose is state. It's shown while showDialog is true, and nothing is "shown" imperatively. With rememberSaveable it's still open after rotation, which the old AlertDialog.Builder().show() couldn't do without extra work.

In SportMap the flag can't stay inside the composable. The notification's Stop button asks for the confirmation from outside the UI, so lecture 15 moves the flag into MapViewModel. Lecture 11 shows the general mechanism it builds on: an intent that reaches the activity becomes state, and the state drives the UI.

Bottom sheets​

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapOptionsSheet(
compassVisible: Boolean,
onCompassVisibleChange: (Boolean) -> Unit,
orientation: MapOrientation,
onOrientationChange: (MapOrientation) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(Modifier.padding(16.dp)) {
Text("Map options", style = MaterialTheme.typography.titleLarge)
OrientationSelector(selected = orientation, onSelect = onOrientationChange)
CompassSwitch(checked = compassVisible, onCheckedChange = onCompassVisibleChange)
}
}
}

Like a dialog, the sheet is shown while you call it (if (showOptions) MapOptionsSheet(...)).

Snackbars​

A snackbar is a short message at the bottom, optionally with one action. Scaffold positions it, and a SnackbarHostState shows it from a coroutine:

val snackbarHostState = remember { SnackbarHostState() }
var lastCheckpoint by remember { mutableStateOf<String?>(null) }

Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { innerPadding ->
Button(onClick = { lastCheckpoint = "CP 3" }, modifier = Modifier.padding(innerPadding)) {
Text("Add CP")
}
}

lastCheckpoint?.let { cp ->
LaunchedEffect(cp) {
val result = snackbarHostState.showSnackbar(message = "$cp added", actionLabel = "Undo")
if (result == SnackbarResult.ActionPerformed) { /* undo */ }
}
}

showSnackbar suspends until the snackbar is dismissed and returns whether the action was clicked. That's why it needs a coroutine: LaunchedEffect (state-driven, as here) or rememberCoroutineScope (from a click, lecture 06).

Experimental APIs

Some Material 3 APIs are marked @ExperimentalMaterial3Api: TopAppBar, ModalBottomSheet and others. They work fine but may change between versions. Add @OptIn(ExperimentalMaterial3Api::class) to the function using them. Android Studio offers it on Alt+Enter.

Adaptive layouts​

Rotation is a configuration change​

Rotating the device, resizing a window, folding a foldable and changing the system language are all configuration changes. By default the activity is recreated and setContent { } runs again. Your state survives if you followed lecture 06 (rememberSaveable) and lecture 10 (ViewModel).

Don't "fix" rotation with configChanges or orientation locks

android:configChanges="orientation|screenSize" in the manifest stops the recreation. Then every bug that recreation would expose shows up later, in process death, language changes and window resizing. android:screenOrientation="portrait" is not an option either (see large screens). Handle state properly instead.

In XML you had layout/ and layout-land/ folders. In Compose, a layout decision is an if.

Orientation​

val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE

LocalConfiguration changes on every configuration change, so the composables reading it recompose with the new value.

Window size classes​

Orientation alone is a poor signal. A tablet in portrait is wider than a phone in landscape, and in split screen a "landscape" device may give your app a narrow window. Window size classes bucket the window size, not the device:

Width classWindow widthTypical device
Compactbelow 600dpphone in portrait
Medium600 - 840dptablet in portrait, foldable unfolded
Expanded840 - 1200dptablet in landscape, phone landscape on some devices
Large / Extra-large1200dp and upbig tablets, desktop windows

Height classes: compact below 480dp (a phone in landscape), medium 480 - 900dp, expanded 900dp and up.

# libs.versions.toml - version comes from the Compose BOM
androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" }
// app/build.gradle.kts
implementation(libs.androidx.compose.material3.adaptive)
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.window.core.layout.WindowSizeClass

@Composable
fun SizeAwareContent() {
val windowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass
val isWide = windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
val isShort = !windowSizeClass.isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND)
// ...
}

currentWindowAdaptiveInfoV2() also reports the Large and Extra-large width classes. The older currentWindowAdaptiveInfo() still works but is deprecated, and much documentation and AI-generated code still uses it, or the even older windowWidthSizeClass == WindowWidthSizeClass.COMPACT enums. Translate those to breakpoint checks.

Use window size classes for app-level decisions: a navigation bar vs a navigation rail, one pane vs list + detail (lecture 11).

Example: map controls at the bottom or at the side​

The final project says: "move buttons from bottom to left-or-right screen edge in landscape mode". The buttons are part of the statistics sections (see the ASCII sketch in the project description). Start/Stop sits next to the overall numbers, "add CP" next to the checkpoint numbers and "add WP" next to the waypoint numbers, so they move together. Using the StatsSection from lecture 05:

/** The three text lines of one statistics section (lecture 13 formats them from TrackingState). */
data class SectionTexts(val distance: String, val secondLine: String, val pace: String)

@Composable
fun ControlSection(
button: @Composable () -> Unit,
title: String,
texts: SectionTexts,
modifier: Modifier = Modifier,
) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
button()
StatsSection(title, texts.distance, texts.secondLine, texts.pace, Modifier.weight(1f))
}
}

@Composable
fun TrackingPanel(
vertical: Boolean,
isTracking: Boolean,
overall: SectionTexts,
checkpoint: SectionTexts,
waypoint: SectionTexts,
onStartStop: () -> Unit,
onAddCheckpoint: () -> Unit,
onAddWaypoint: () -> Unit,
modifier: Modifier = Modifier,
) {
// Each section is defined once and receives its modifier from the parent layout
val overallSection: @Composable (Modifier) -> Unit = { m ->
ControlSection(
button = {
FilledIconButton(onClick = onStartStop) {
if (isTracking) Icon(Icons.Default.Close, contentDescription = "Stop")
else Icon(Icons.Default.PlayArrow, contentDescription = "Start")
}
},
title = "Overall", texts = overall, modifier = m
)
}
val checkpointSection: @Composable (Modifier) -> Unit = { m ->
ControlSection(
button = {
IconButton(onClick = onAddCheckpoint, enabled = isTracking) {
Icon(Icons.Default.LocationOn, contentDescription = "Add checkpoint")
}
},
title = "CP", texts = checkpoint, modifier = m
)
}
val waypointSection: @Composable (Modifier) -> Unit = { m ->
ControlSection(
button = {
IconButton(onClick = onAddWaypoint, enabled = isTracking) {
Icon(Icons.Default.Place, contentDescription = "Add waypoint")
}
},
title = "WP", texts = waypoint, modifier = m
)
}

if (vertical) {
Column(modifier, verticalArrangement = Arrangement.SpaceEvenly) {
overallSection(Modifier)
checkpointSection(Modifier)
waypointSection(Modifier)
}
} else {
Row(modifier) {
overallSection(Modifier.weight(1f)) // weight exists only in RowScope - created here
checkpointSection(Modifier.weight(1f))
waypointSection(Modifier.weight(1f))
}
}
}

@Composable
fun AdaptiveMapLayout(
map: @Composable (Modifier) -> Unit, // slot: the caller decides what the map is
panel: @Composable (vertical: Boolean, modifier: Modifier) -> Unit,
modifier: Modifier = Modifier,
) {
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
if (isLandscape) {
Row(modifier.fillMaxSize()) {
map(Modifier.weight(1f).fillMaxHeight())
panel(true, Modifier.fillMaxHeight().width(200.dp).padding(8.dp)) // right edge
}
} else {
Column(modifier.fillMaxSize()) {
map(Modifier.weight(1f).fillMaxWidth())
panel(false, Modifier.fillMaxWidth().padding(8.dp)) // bottom
}
}
}

// Usage with sample numbers - lecture 18 wires it to MapViewModel
@Composable
fun AdaptiveMapLayoutPreviewContent() {
AdaptiveMapLayout(
map = { m -> Box(m.background(MaterialTheme.colorScheme.surfaceVariant)) { Text("Map (lecture 16)") } },
panel = { vertical, m ->
TrackingPanel(
vertical = vertical,
isTracking = true,
overall = SectionTexts("15 000 m", "1:35:47", "6:23"),
checkpoint = SectionTexts("1500 m", "1137 m", "6:37"),
waypoint = SectionTexts("783 m", "332 m", "7:35"),
onStartStop = {}, onAddCheckpoint = {}, onAddWaypoint = {},
modifier = m,
)
},
)
}
  • Portrait: one row of three sections under the map, each with its button. Landscape: the same three sections stacked in a column at the right edge. Call panel(...) before map(...) in the Row to use the left edge, for example as a "left-handed" setting.
  • Modifier.weight only exists inside a Row/Column scope. Passing the modifier into the section lambdas lets the same section composable be used in both layouts.
  • map and panel are slots: composable parameters, like Scaffold's topBar. The layout only decides where things go, and the caller decides what they are. That keeps the layout reusable and previewable.
  • CP and WP are disabled until tracking runs. Stop doesn't stop immediately: onStartStop asks for confirmation (the dialog above, owned by MapViewModel in lecture 15).
  • The icons are from material-icons-core. Your own vector assets (a flag for CP, for example) work the same way with painterResource.

The map is the same composable in both branches. Keep its state (camera, markers) hoisted above the if or in a ViewModel, otherwise switching branches recreates it.

BoxWithConstraints​

Sometimes a component should adapt to the space it gets, not to the window. BoxWithConstraints exposes the incoming constraints as maxWidth/maxHeight:

@Composable
fun AdaptiveBoardLayout(
board: List<List<String>>,
onCellClick: (Int, Int) -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier.fillMaxSize()) {
if (maxWidth > maxHeight) {
Row(Modifier.fillMaxSize()) {
GameInfo(Modifier.weight(1f)) // your stats + "New game"
Board(board, onCellClick, Modifier.fillMaxHeight())
}
} else {
Column(Modifier.fillMaxSize()) {
GameInfo(Modifier.weight(1f))
Board(board, onCellClick, Modifier.fillMaxWidth())
}
}
}
}

Board (lecture 05) applies aspectRatio(1f) itself, so filling the height or the width is enough to make it as big as possible. That's exactly HW2's "stats on top, board below in portrait; stats left, board right in landscape". It works in split-screen and freeform windows too, because it looks at the real available space.

Large screens ignore orientation locks​

For apps that target Android 16 (API 36) or newer, on displays with a smallest width of 600dp or more (tablets, unfolded foldables, desktop windows), the system ignores:

  • android:screenOrientation (portrait, landscape and their variants) and setRequestedOrientation();
  • android:resizeableActivity="false";
  • minAspectRatio / maxAspectRatio.

The app fills the whole window in whatever orientation the device is in. The temporary opt-out property (PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY) stops working once you target API 37, and the course targets 37. So every screen must work in both orientations and at every size. The final project explicitly says "All screen sizes are supported, even tablets". Test on a tablet emulator (Device Manager → Create → Tablet) and with resizable emulator profiles.

Drawing with Canvas​

When no component fits (a board grid, a gauge, a compass), draw it yourself. Canvas(modifier) { } gives you a DrawScope:

  • size, center, and size.minDimension describe the drawing area in pixels. Convert dp with 4.dp.toPx(), which is available inside the scope.
  • drawLine, drawRect, drawRoundRect, drawCircle, drawArc, drawPath, drawImage, and drawText with rememberTextMeasurer().
  • rotate(degrees) { }, translate(left, top) { }, scale(...) { } and inset(...) { } transform everything drawn inside the block.
  • Fill is the default, and style = Stroke(width = ...) draws outlines.

Read theme colors outside the Canvas lambda. The lambda isn't composable.

Board grid lines​

@Composable
fun BoardGrid(size: Int, modifier: Modifier = Modifier) {
val lineColor = MaterialTheme.colorScheme.outline
Canvas(modifier.aspectRatio(1f)) {
val cell = this.size.width / size
for (i in 1 until size) {
drawLine(lineColor, Offset(i * cell, 0f), Offset(i * cell, this.size.height), strokeWidth = 4.dp.toPx())
drawLine(lineColor, Offset(0f, i * cell), Offset(this.size.width, i * cell), strokeWidth = 4.dp.toPx())
}
}
}

Only inner lines are drawn, so there's no outer border. Put it in a Box behind the cells, or draw it over them.

A pace gauge​

@Composable
fun PaceGauge(paceSecPerKm: Double?, paceMin: Int, paceMax: Int, modifier: Modifier = Modifier) {
val fraction = paceSecPerKm
?.let { ((it - paceMin) / (paceMax - paceMin)).coerceIn(0.0, 1.0).toFloat() }
?: 0f
val animated by animateFloatAsState(targetValue = fraction, animationSpec = tween(500), label = "pace")
val trackColor = MaterialTheme.colorScheme.surfaceVariant
val indicatorColor = MaterialTheme.colorScheme.primary

Canvas(modifier.size(120.dp)) {
val stroke = Stroke(width = 12.dp.toPx(), cap = StrokeCap.Round)
drawArc(trackColor, startAngle = 135f, sweepAngle = 270f, useCenter = false, style = stroke)
drawArc(indicatorColor, startAngle = 135f, sweepAngle = 270f * animated, useCenter = false, style = stroke)
rotate(degrees = 135f + 270f * animated) { // rotates around the center
drawLine(indicatorColor, start = center, end = Offset(center.x + size.minDimension / 2.5f, center.y),
strokeWidth = 4.dp.toPx())
}
drawCircle(indicatorColor, radius = 6.dp.toPx())
}
}

Angles are in degrees, clockwise, with 0° pointing right (3 o'clock). The compass in lecture 17 uses the same tools: a circle, ticks drawn inside rotate, and a needle.

Drawing over content​

Modifier.drawBehind { } draws under a composable's content. Modifier.drawWithContent { drawContent(); ... } draws over it, for example a line through a winning row:

@Composable
fun WinLineOverlay(from: Pair<Int, Int>?, to: Pair<Int, Int>?, size: Int, modifier: Modifier = Modifier) {
val color = LocalGameColors.current.winLine
Box(
modifier.drawWithContent {
drawContent()
if (from != null && to != null) {
val cell = this.size.width / size
fun centerOf(p: Pair<Int, Int>) = Offset((p.second + 0.5f) * cell, (p.first + 0.5f) * cell)
drawLine(color, centerOf(from), centerOf(to), strokeWidth = 8.dp.toPx(), cap = StrokeCap.Round)
}
}
) {
// the board goes here
}
}

Animations​

Compose animations are state: you set a target and Compose animates towards it.

// A value
val background by animateColorAsState(
targetValue = if (highlighted) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surface,
label = "cell"
)

// Appearing and disappearing - e.g. the WP section exists only while a waypoint is set
AnimatedVisibility(visible = waypointVisible, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically()) {
Text("WP 332 m")
}

// Size changes of a composable
Column(Modifier.animateContentSize().clickable { expanded = !expanded }) {
Text("Session 12")
if (expanded) Text("Details...")
}

// Several values driven by one state
val transition = updateTransition(targetState = highlighted, label = "cell")
val elevation by transition.animateDp(label = "elevation") { if (it) 8.dp else 0.dp }
val scale by transition.animateFloat(label = "scale") { if (it) 1.1f else 1f }
  • animateFloatAsState, animateDpAsState, animateColorAsState, animateOffsetAsState... all take a targetValue and an optional animationSpec (tween(durationMillis), spring(), keyframes { }).
  • The label shows up in Android Studio's Animation Preview.
  • For values that change every frame (rotation, position), read them in a deferred lambda so only the draw phase runs (lecture 06):
val rotation by animateFloatAsState(targetValue = -azimuth, label = "needle")
Icon(painterResource(R.drawable.ic_needle), contentDescription = null,
modifier = Modifier.graphicsLayer { rotationZ = rotation })

App icon​

The final project requires: "Change app icon into something more meaningful." The launcher icon is an adaptive icon: two layers that the launcher masks (circle, squircle...) and can animate.

res → New → Image Asset → Launcher Icons (Adaptive and Legacy):

  • Foreground layer: your logo, a vector or image. The layers are 108×108dp, but only the inner 72dp are visible, and the logo should stay inside the central 66dp safe zone.
  • Background layer: a color or image.
  • Monochrome layer (Android 13+): a single-color version. With "Themed icons" on, the launcher tints it with the wallpaper colors. Without it, your icon looks out of place on themed home screens.

Studio generates mipmap-anydpi-v26/ic_launcher.xml plus legacy PNGs:

<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

The manifest already points to it (android:icon="@mipmap/ic_launcher", android:roundIcon=...). Android 12+ also uses the icon for the system splash screen. For a custom splash, use androidx.core:core-splashscreen.

In the final project​

  • Theme: a SportMap theme from Material Theme Builder, dark mode supported. Track speed colors (lecture 16) are app-specific values, not dynamic colors.
  • Top bar: map controls (north-up toggle, reset, compass, options) as icon buttons plus an overflow DropdownMenu.
  • Stop confirmation: an AlertDialog. Its visibility flag lives in MapViewModel (lecture 15), because the notification's Stop button can request it from outside the UI. Lecture 11 shows the general intent → state → navigation mechanism, and lecture 15 owns the dialog state.
  • Options: a ModalBottomSheet or settings screen with SegmentedButton (map orientation), Switch (compass, keep centered) and Slider (GPS interval, sync interval).
  • Landscape: AdaptiveMapLayout + TrackingPanel. The Start/Stop, add CP and add WP buttons move together with their statistics: a row at the bottom in portrait, a column at the screen edge in landscape. It works on tablets, because Android 16+ won't let you lock orientation there.
  • Canvas: the compass (lecture 17) and, optionally, a pace legend or gauge.
  • App icon: adaptive with a monochrome layer.

Self preparation QA​

  1. Why should components use color roles like primaryContainer instead of hex colors? — Roles adapt automatically to light/dark, dynamic color and contrast settings. Pairing each role with its on... role guarantees readable contrast.
  2. How do you add dark mode to a Compose app? — Provide a dark color scheme and choose it when isSystemInDarkTheme() (or a user setting) is true. Every component reads colors from MaterialTheme, so nothing else changes.
  3. When should you turn dynamic color off? — When colors carry meaning or brand identity (player colors, speed colors). Wallpaper-derived colors would change or remove that meaning.
  4. Why is a Compose dialog still open after rotation, and how do you get that? — It's shown while state says so. Keeping that flag in rememberSaveable (or a ViewModel) restores it after the activity is recreated.
  5. Why are window size classes better than orientation for app-level layout decisions? — They describe the actual window size, which covers tablets in portrait, foldables, split screen and desktop windows, where orientation gives the wrong answer.
  6. What happens to android:screenOrientation="portrait" on a tablet when you target API 36+? — It's ignored on displays of 600dp smallest width and more, so the app must support every orientation and size.
  7. When do you use BoxWithConstraints? — When a component should adapt to the space it actually receives, for example placing the board next to the stats when the available width exceeds the height.
  8. How do you draw something on top of a composable's content? — Modifier.drawWithContent { drawContent(); /* draw here */ }. drawBehind draws below the content.
  9. Why read a fast-changing animated value inside graphicsLayer { }? — The read happens in the draw phase, so each frame only redraws and doesn't recompose.
  10. What is the monochrome layer of an adaptive icon for? — Android 13+ themed icons: the launcher tints the single-color layer with the user's theme colors.