You can now build lists, grids and interactive screens. But if you hand your phone to a friend, the first thing they will judge is not your code. It is how the app looks and feels. Today you learn how to make an app look like it belongs on a modern phone, and how to make it feel alive with small, tasteful animations.
Think of a restaurant. The food (your features) matters most, but the plates, the lighting, the uniforms and the way the waiter brings dishes to the table all shape the experience. Material Design 3 is Google's complete design kit for apps: colours, type, shapes and motion that work together. Theming means defining those choices once so that every screen of your app uses them automatically.
By the end of this lesson you will be able to:
- Explain Material Design 3 and the idea of a theme.
- Define a
ColorSchemewithlightColorSchemeanddarkColorSchemeand understand colour roles such asprimaryandonPrimary. - Turn on dynamic colour safely on Android 12 and above.
- Support dark theme with
isSystemInDarkTheme(). - Define custom
TypographyandShapesand use them throughMaterialTheme. - Apply edge-to-edge basics with
Scaffoldinsets. - Animate size, colour and visibility with
animateContentSize,animateDpAsState,animateColorAsStateandAnimatedVisibility. - Build an expand and collapse card.
- Complete the Superheroes practice app and the 30 Days project.
How this fits the bigger picture: This lesson follows Unit 3, Pathway 3 of Google's Android Basics with Compose course. It covers Intro to Material Design with Compose, Material Theming with Jetpack Compose, Simple animation with Jetpack Compose, Practice: Build Superheroes app and Project: Create a 30 Days App. Our apps are original, so we use fictional heroes and our own 30-day plan. Lesson 40 revisits theming in much more depth and lesson 41 does the same for animation.
1. Composables, modifiers and layouts (lessons 15 and 16)
You will use Row, Column, Card, Text, Image and Modifier chains.
2. State (lessons 17 and 19)
An animation needs a state to react to. A card has an expanded state, a Boolean. When it flips, the animation runs from the old look to the new one. If remember and mutableStateOf feel unfamiliar, revisit lesson 17.
3. Lazy lists and resources (lesson 21)
The Superheroes app is a LazyColumn of cards driven by a data class with @StringRes and @DrawableRes ids, exactly as in the Affirmations app.
4. Lambdas and function parameters with defaults (lessons 08 and 12)
Our theme function takes defaults like darkTheme: Boolean = isSystemInDarkTheme() and a content lambda.
What is Material Design?
Material Design is Google's design system: a shared set of rules, components and tools for building consistent, good-looking apps. Material 3 (often written M3 or Material You) is the current generation, and androidx.compose.material3 is its Compose library. Every Button, Card and TopAppBar you have used already comes from it.
A design system saves you from re-deciding things. You do not choose a new shade of blue for every button. You say "use the primary colour", and the system gives the same one everywhere.
The three pillars of a theme
MaterialTheme(
colorScheme = ..., // which colours, and what each is for
typography = ..., // which text styles (sizes, weights, fonts)
shapes = ... // how rounded the corners are
) {
// your whole app lives here
}
Anything inside reads the values through MaterialTheme.colorScheme, MaterialTheme.typography and MaterialTheme.shapes. Change the theme in one place and the entire app changes.
Colour roles
A colour scheme is not a list of colours. It is a list of jobs, and each job has a colour. This is why dark mode is easy: you simply provide a second set of colours for the same jobs.
| Role | Meaning | Typical use |
|---|---|---|
primary |
Your brand's main colour | Filled buttons, active controls |
onPrimary |
Text and icons drawn on primary | Label inside a filled button |
primaryContainer |
A softer version of primary | Highlighted cards, chips |
onPrimaryContainer |
Content drawn on the container | Text on a highlighted card |
secondary, tertiary |
Supporting accents | Less important actions, contrast |
background and surface |
The page and card colours | Screen and card backgrounds |
onBackground, onSurface |
Content drawn on those | Ordinary text |
error |
Warnings | Error messages |
The golden rule: always pair a colour with its "on" colour. Text on a primary background must use onPrimary. The pair has been chosen to have enough contrast, so people with weak eyesight and people in bright sunlight can still read it (lessons 46 and 47 measure contrast).
Where do the colours come from?
Designers create schemes from a single seed colour using the Material Theme Builder tool, which can export Kotlin code. You can also write them by hand, as we will. Choose a primary that suits your app, then derive lighter and darker tones.
Dynamic colour
On Android 12 (API 31) and above, the phone can build a colour scheme from the user's wallpaper. Your app then matches the user's personalised look. You ask for it with dynamicLightColorScheme(context) or dynamicDarkColorScheme(context). On older phones those functions do not exist, so you must check the version and fall back to your own colours.
Dark theme
Many people prefer dark screens, especially at night, and on OLED phones dark screens can save some battery. Android tells you whether the user chose dark through isSystemInDarkTheme(). Your theme then picks darkColorScheme or lightColorScheme. A well-themed app needs no per-screen dark mode code at all.
Typography and shapes
M3 gives you a type scale, named styles that you use rather than hard-coding sizes: displayLarge, headlineSmall, titleMedium, bodyLarge, labelMedium and so on, fifteen in all. Use a style by name: Text("Hello", style = MaterialTheme.typography.headlineSmall). Shapes work likewise: small, medium and large (and two more, extraSmall and extraLarge) describe corner radii, and components pick the right one for their size.
We will theme the Superheroes app. Create a project Superheroes with package com.techbase.superheroes. If the wizard created a ui/theme folder with Color.kt, Theme.kt and Type.kt, we will replace their contents. If your Android Studio version names things differently, create the files yourself in a package com.techbase.superheroes.ui.theme. Any theme named SuperheroesTheme, or whatever the wizard generated from your project name, will do; just be consistent.
Step 1: Colours
package com.techbase.superheroes.ui.theme
import androidx.compose.ui.graphics.Color
// Light theme
val LightPrimary = Color(0xFF006B5A)
val LightOnPrimary = Color(0xFFFFFFFF)
val LightPrimaryContainer = Color(0xFF9CF2DC)
val LightOnPrimaryContainer = Color(0xFF00201A)
val LightSecondary = Color(0xFF8A5100)
val LightOnSecondary = Color(0xFFFFFFFF)
val LightSecondaryContainer = Color(0xFFFFDCBA)
val LightBackground = Color(0xFFFBFDF9)
val LightOnBackground = Color(0xFF191C1B)
val LightSurface = Color(0xFFFBFDF9)
val LightOnSurface = Color(0xFF191C1B)
// Dark theme
val DarkPrimary = Color(0xFF80D5C0)
val DarkOnPrimary = Color(0xFF00382E)
val DarkPrimaryContainer = Color(0xFF005143)
val DarkOnPrimaryContainer = Color(0xFF9CF2DC)
val DarkSecondary = Color(0xFFFFB86C)
val DarkOnSecondary = Color(0xFF4A2800)
val DarkSecondaryContainer = Color(0xFF693C00)
val DarkBackground = Color(0xFF191C1B)
val DarkOnBackground = Color(0xFFE0E3E0)
val DarkSurface = Color(0xFF191C1B)
val DarkOnSurface = Color(0xFFE0E3E0)
Color(0xFF006B5A) reads as: 0x means hexadecimal, FF is full opacity, and 006B5A are the red, green and blue amounts (so here, a deep teal). Notice the pattern: in light mode the primary is dark with white text on it; in dark mode the primary becomes light with dark text. That inversion is what keeps contrast strong in both.
These values are an original palette made for this lesson. For your own app, try the Material Theme Builder at m3.material.io to generate a palette from your brand colour.
Step 2: Typography
package com.techbase.superheroes.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 30.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 18.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
)
)
You only list the styles you want to change; the remaining styles in the type scale keep Material's defaults. Sizes are in sp (scale-independent pixels), which grow when the user raises the phone's font size. Always use sp for text and dp for everything else (lesson 15).
FontFamily.Default is the phone's normal font. To use a custom font, you place a font file in res/font, then write FontFamily(Font(R.font.my_font)). Fonts can also be downloaded on demand. Lesson 40 covers custom, downloadable and variable fonts. Remember that fonts have licences and add to your app size.
Step 3: Shapes
package com.techbase.superheroes.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val AppShapes = Shapes(
small = RoundedCornerShape(50),
medium = RoundedCornerShape(bottomStart = 16.dp, topEnd = 16.dp)
)
RoundedCornerShape(50) means 50 percent: fully round ends, which gives a square image a perfect circle, or a rectangle a "pill" look. RoundedCornerShape(bottomStart = 16.dp, topEnd = 16.dp) rounds just two corners, which gives cards a distinctive leaf-like shape. Shapes you do not specify keep the defaults.
Step 4: The theme function
package com.techbase.superheroes.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
primaryContainer = LightPrimaryContainer,
onPrimaryContainer = LightOnPrimaryContainer,
secondary = LightSecondary,
onSecondary = LightOnSecondary,
secondaryContainer = LightSecondaryContainer,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface
)
private val DarkColors = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
secondaryContainer = DarkSecondaryContainer,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface
)
@Composable
fun SuperheroesTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
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 -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
How it works:
lightColorScheme(...)anddarkColorScheme(...)build a scheme; roles you leave out use sensible defaults.private valkeeps the two schemes inside this file. Only the theme function is public.- The
whenhas three branches, tried in order: use dynamic colour if wanted and the phone is Android 12 or newer (Build.VERSION_CODES.Sis API 31); otherwise dark colours if the user is in dark mode; otherwise light colours. dynamicColordefaults tofalsehere so that you can see your own palette while learning. The Android Studio template turns it on by default. TrydynamicColor = trueon an Android 12+ emulator and change the wallpaper to see the colours follow.content: @Composable () -> Unitis the screen the theme wraps.MaterialTheme(..., content = content)passes it along.
The official Practice: Build Superheroes app asks for a scrolling list of heroes with a name, a description and a picture, themed with Material 3. Here is our version, with fictional heroes of our own invention.
The look we want
+------------------------------------------+
| Superheroes (top app bar) |
+------------------------------------------+
| +--------------------------------------+ |
| | Captain Danfo [ o ] | |
| | Drives through any traffic jam | |
| +--------------------------------------+ |
| +--------------------------------------+ |
| | Lady Jollof [ o ] | |
| | Feeds a whole street from one pot | |
| +--------------------------------------+ |
+------------------------------------------+
Each card has text on the left and a round picture, 72 dp wide, on the right.
Resources
Add these strings:
<resources>
<string name="app_name">Superheroes</string>
<string name="hero1_name">Captain Danfo</string>
<string name="hero1_description">Drives through any traffic jam and never loses the way.</string>
<string name="hero2_name">Lady Jollof</string>
<string name="hero2_description">Feeds a whole street from one enormous pot.</string>
<string name="hero3_name">Professor Generator</string>
<string name="hero3_description">Keeps the lights on when everyone else is in darkness.</string>
<string name="hero4_name">The Mighty Umbrella</string>
<string name="hero4_description">Stops the rain, shades the sun and still fits in a bag.</string>
<string name="hero5_name">Agent Network</string>
<string name="hero5_description">Finds one bar of signal anywhere in the city.</string>
</resources>
For the pictures, create three vector drawables hero1.xml, hero2.xml and hero3.xml with the same simple circle design you used in lesson 21 (any square viewport, a coloured background and a lighter circle). Replace them with real artwork for a finished app. Heroes 4 and 5 reuse hero1 and hero2.
The data
package com.techbase.superheroes.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.techbase.superheroes.R
data class Hero(
@StringRes val nameRes: Int,
@StringRes val descriptionRes: Int,
@DrawableRes val imageRes: Int
)
val heroes = listOf(
Hero(R.string.hero1_name, R.string.hero1_description, R.drawable.hero1),
Hero(R.string.hero2_name, R.string.hero2_description, R.drawable.hero2),
Hero(R.string.hero3_name, R.string.hero3_description, R.drawable.hero3),
Hero(R.string.hero4_name, R.string.hero4_description, R.drawable.hero1),
Hero(R.string.hero5_name, R.string.hero5_description, R.drawable.hero2)
)
The screen
package com.techbase.superheroes
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.techbase.superheroes.model.Hero
import com.techbase.superheroes.model.heroes
import com.techbase.superheroes.ui.theme.SuperheroesTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SuperheroesTheme {
SuperheroesApp()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SuperheroesApp() {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.displayLarge
)
}
)
}
) { innerPadding ->
HeroList(heroes = heroes, contentPadding = innerPadding)
}
}
@Composable
fun HeroList(
heroes: List<Hero>,
contentPadding: PaddingValues,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = contentPadding.calculateTopPadding() + 8.dp,
bottom = contentPadding.calculateBottomPadding() + 8.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(heroes, key = { it.nameRes }) { hero ->
HeroItem(hero)
}
}
}
@Composable
fun HeroItem(hero: Hero, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(hero.nameRes),
style = MaterialTheme.typography.displayMedium
)
Text(
text = stringResource(hero.descriptionRes),
style = MaterialTheme.typography.bodyLarge
)
}
Spacer(modifier = Modifier.width(16.dp))
Image(
painter = painterResource(hero.imageRes),
contentDescription = null,
modifier = Modifier
.size(72.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
}
}
}
@Preview(showBackground = true)
@Composable
fun SuperheroesLightPreview() {
SuperheroesTheme(darkTheme = false) {
SuperheroesApp()
}
}
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
fun SuperheroesDarkPreview() {
SuperheroesTheme(darkTheme = true) {
SuperheroesApp()
}
}
What the screen shows: a centred title bar reading Superheroes, then five cards. Each card has a bold name, a description, and on the right a circular picture (because shapes.small is a 50-percent rounded corner). Switch the emulator to dark mode (Settings > Display > Dark theme) and the colours change to the dark scheme, with no extra code in the screen.
Points to understand:
enableEdgeToEdge()(fromandroidx.activity) lets the app draw behind the status and navigation bars, as modern apps do. It also makes the system bar icons readable against your colours. New projects from the wizard usually include this call already; if yours does, you do not need to add it twice. It is available in recent versions of theactivitylibrary; if it is red, update the dependency.- Insets: because content may now sit under the system bars,
ScaffoldpassesinnerPaddingdescribing how much space the top bar and system bars take. We add it to the list'scontentPadding, so the first card starts below the top bar, the last card clears the navigation bar, and the list still scrolls behind them. That is why we usecontentPaddingand notModifier.padding. @OptIn(ExperimentalMaterial3Api::class): some Material 3 components are labelled experimental, meaning their API may change. TheOptInannotation tells the compiler you accept that. WhetherCenterAlignedTopAppBarstill needs it depends on your library version; leaving it in is harmless.Modifier.weight(1f)inside aRowgives the text column all the space the picture does not use, so a long description wraps instead of pushing the picture off screen.clip(MaterialTheme.shapes.small)cuts the image to the theme's small shape. ChangeAppShapesand every clipped image changes. That is the point of a theme.- Two previews, one light and one dark, let you check both modes without running the app.
uiMode = UI_MODE_NIGHT_YESasks the preview for night mode.
Check the contrast
Try this once: in Color.kt change LightOnPrimary to a dark colour and look at a filled button or the title bar. Text on primary becomes hard to read. That is why role pairs exist.
Change the primary colour of your light theme to something you like (for example 0xFF1E88E5). Then change the heroes' picture shape by editing only AppShapes.small, for instance to RoundedCornerShape(8.dp). Notice that no screen code needed to change.
Motion makes an interface understandable. When a card grows smoothly, you see what changed. When it just jumps, your eyes must work out what happened. Compose makes simple animations a one-line change.
The idea: animate from state
You never say "move this by 5 pixels per frame". Instead you describe the target for each state, and Compose animates between them whenever the state changes:
expanded = false ------ state flips ------> expanded = true
padding 8 dp padding 24 dp
colour primaryContainer colour secondaryContainer
Compose draws every frame in between, smoothly.
Tool 1: animateContentSize
The simplest tool. Add it to a modifier, and any change to the size of that composable's content is animated.
Column(
modifier = Modifier
.animateContentSize()
.padding(16.dp)
) { /* content that can grow or shrink */ }
Import androidx.compose.animation.animateContentSize. Place it before the padding in the chain so the padding is included in the animated size.
Tool 2: animateDpAsState
Animates a Dp value, such as padding, size or elevation.
val extraPadding by animateDpAsState(
targetValue = if (expanded) 24.dp else 0.dp,
label = "extraPadding"
)
by works here for the same reason it did in lesson 17. animateDpAsState returns a state: its value moves step by step to the target. Imports: androidx.compose.animation.core.animateDpAsState and androidx.compose.runtime.getValue. The label is a name that animation tools use when inspecting your app (lesson 44).
Tool 3: animateColorAsState
Animates between two colours:
val containerColor by animateColorAsState(
targetValue = if (expanded) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.primaryContainer
},
label = "containerColor"
)
Because the colours come from the theme, the animation works in dark mode too.
Tool 4: AnimatedVisibility
Fades and slides content in and out when a condition changes, instead of popping it in:
AnimatedVisibility(visible = expanded) {
Text("More details appear here")
}
Import androidx.compose.animation.AnimatedVisibility.
Controlling the feel: spring and tween
animateContentSize and the animate*AsState functions take an animationSpec. Two common ones:
| Spec | Behaviour | Example |
|---|---|---|
spring(dampingRatio, stiffness) |
Like a spring: it may overshoot and bounce, and speed depends on stiffness. | spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) |
tween(durationMillis, easing) |
A fixed duration with a smooth curve. | tween(durationMillis = 300) |
Keep animations short and subtle. A card that bounces for two seconds becomes annoying quickly, and some users have turned animations off in their phone's settings for comfort. Lesson 41 covers the whole animation toolbox.
Now we combine everything: a card that expands to show more, with animated colour and spacing. We upgrade the Hero data to include a longer story, and add a state to each card.
Add a field @StringRes val storyRes: Int to Hero, give each hero a long heroN_story string in strings.xml, and add the extra argument in each Hero(...) line, for instance Hero(R.string.hero1_name, R.string.hero1_description, R.drawable.hero1, R.string.hero1_story). (The solution at the end of the lesson avoids the extra resources for brevity.)
Then replace HeroItem with:
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
fun HeroItem(hero: Hero, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
val containerColor by animateColorAsState(
targetValue = if (expanded) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.primaryContainer
},
label = "containerColor"
)
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = containerColor)
) {
Column(
modifier = Modifier
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium
)
)
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(hero.nameRes),
style = MaterialTheme.typography.displayMedium
)
Text(
text = stringResource(hero.descriptionRes),
style = MaterialTheme.typography.bodyLarge
)
}
Spacer(modifier = Modifier.width(16.dp))
Image(
painter = painterResource(hero.imageRes),
contentDescription = null,
modifier = Modifier
.size(72.dp)
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Crop
)
}
AnimatedVisibility(visible = expanded) {
Text(
text = stringResource(hero.storyRes),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 12.dp)
)
}
TextButton(onClick = { expanded = !expanded }) {
Text(if (expanded) "Show less" else "Show more")
}
}
}
}
What the screen shows: each card has a Show more text button. Tapping it flips expanded. The card's background shifts smoothly from the primary container colour to the secondary container colour, the story text fades and slides in, and the card grows to fit it with a slightly springy motion. Tapping Show less plays the same animation in reverse.
Details worth knowing:
expandedis plain state per card. BecauseHeroItemis called once per hero, each hero has its ownexpanded. (If you want the open/closed state to survive rotation, userememberSaveable, lesson 19. And because lazy items scroll out of composition,rememberSaveablekeeps the state when the item returns to the screen; plainrememberdoes not.)!expandedflips a Boolean:truebecomesfalseandfalsebecomestrue.- The button text is hard-coded here to keep the example short. In your finished app, put
Show moreandShow lessinstrings.xml. - If you want an accessibility improvement, replace the text button with an icon button that carries a
contentDescriptionof "Expand" or "Collapse" (lesson 47).
Exercise 1: Pulse on tap
Objective: Use animateDpAsState.
Make a square Box (80 dp) with a coloured background that is clickable. Each tap toggles between 80 dp and 160 dp, animated with animateDpAsState and a spring. Use Modifier.size(size) with the animated value and Modifier.clickable { ... }.
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PulseBox(modifier: Modifier = Modifier) {
var big by remember { mutableStateOf(false) }
val boxSize by animateDpAsState(
targetValue = if (big) 160.dp else 80.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "boxSize"
)
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(boxSize)
.background(MaterialTheme.colorScheme.primary)
.clickable { big = !big }
)
}
}
The target depends on big. Each tap changes big, and animateDpAsState moves boxSize towards the new target through a bouncy spring, so the square slightly overshoots before settling. The outer Box centres the square on the screen.
Exercise 2: Dark-mode-ready theme for a church noticeboard
Objective: Build a complete theme from your own palette.
Create NoticeTheme with a light and a dark ColorScheme of your own choice (at least primary, onPrimary, background, onBackground, surface and onSurface), a typography with a bold headlineMedium, and a Shapes with a medium corner of 20 dp. Write one screen with a title, a card and a button that uses these styles by role, and two @Preview functions (light and dark).
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val NoticeLight = lightColorScheme(
primary = Color(0xFF5E35B1),
onPrimary = Color.White,
background = Color(0xFFFFFBFF),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFFFFBFF),
onSurface = Color(0xFF1C1B1F)
)
private val NoticeDark = darkColorScheme(
primary = Color(0xFFD0BCFF),
onPrimary = Color(0xFF381E72),
background = Color(0xFF1C1B1F),
onBackground = Color(0xFFE6E1E5),
surface = Color(0xFF1C1B1F),
onSurface = Color(0xFFE6E1E5)
)
private val NoticeTypography = Typography(
headlineMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp)
)
private val NoticeShapes = Shapes(medium = RoundedCornerShape(20.dp))
@Composable
fun NoticeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) NoticeDark else NoticeLight,
typography = NoticeTypography,
shapes = NoticeShapes,
content = content
)
}
@Composable
fun NoticeScreen() {
Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.padding(24.dp)) {
Text("This Sunday", style = MaterialTheme.typography.headlineMedium)
Card(modifier = Modifier.padding(vertical = 16.dp)) {
Text(
"Choir practice holds after the service.",
modifier = Modifier.padding(16.dp)
)
}
Button(onClick = { }) { Text("I will attend") }
}
}
}
@Preview(showBackground = true)
@Composable
fun NoticeLightPreview() { NoticeTheme(darkTheme = false) { NoticeScreen() } }
@Preview(
showBackground = true,
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun NoticeDarkPreview() { NoticeTheme(darkTheme = true) { NoticeScreen() } }
The screen code contains no colours at all: it uses MaterialTheme.typography.headlineMedium and the default component colours, which read from the theme. That is why the dark preview works without a single if in NoticeScreen. Because Surface uses the theme's surface and onSurface, the Text colour is chosen correctly in both modes.
Exercise 3: Animated FAQ list
Objective: Combine LazyColumn, state and AnimatedVisibility.
Build a screen with five questions and answers (a data class Faq(question: String, answer: String)). Each row shows the question; tapping the row reveals or hides the answer with AnimatedVisibility. Only one answer should be open at a time (hold the open index in the parent).
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class Faq(val question: String, val answer: String)
@Composable
fun FaqList(faqs: List<Faq>, modifier: Modifier = Modifier) {
var openIndex by remember { mutableStateOf(-1) }
LazyColumn(modifier = modifier) {
itemsIndexed(faqs) { index, faq ->
val isOpen = index == openIndex
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { openIndex = if (isOpen) -1 else index }
.padding(16.dp)
) {
Text(faq.question, style = MaterialTheme.typography.titleMedium)
AnimatedVisibility(visible = isOpen) {
Text(faq.answer, modifier = Modifier.padding(top = 8.dp))
}
}
HorizontalDivider()
}
}
}
The state is hoisted to FaqList: one openIndex for all rows, -1 meaning "none open". Tapping an open row closes it; tapping another row moves the open index there, which closes the old one automatically, because its isOpen becomes false and AnimatedVisibility plays the exit animation.
Mistake #1: Hard-coding colours in screens. The wrong code: Text("Hi", color = Color(0xFF006B5A)) in many places. The problem: dark mode and rebranding require editing every file, and the colour may be unreadable in dark mode. The fix: use MaterialTheme.colorScheme.primary and its siblings, and define colours only in the theme.
Mistake #2: Mismatched role pairs. The wrong code: text coloured onSurface on a primary background. The problem: low contrast, so some users cannot read it. The fix: pair primary with onPrimary, primaryContainer with onPrimaryContainer, and so on. Card, Button and Surface already choose the right content colour when you use their defaults.
Mistake #3: Calling dynamic colour on old phones. The wrong code: dynamicLightColorScheme(context) with no version check. The problem: the app crashes on Android 11 or older. The fix: guard it with Build.VERSION.SDK_INT >= Build.VERSION_CODES.S as in our theme.
Mistake #4: Text sizes in dp. The wrong code: fontSize = 16.dp. The problem: it will not compile (a TextUnit is expected), and where it is forced, text will ignore the user's font-size setting. The fix: text always uses sp: fontSize = 16.sp.
Mistake #5: Using Modifier.padding(innerPadding) on a scrolling list under a top bar. The problem: it works, but content cannot scroll behind the bars, so it looks cut off instead of flowing underneath. The fix: pass the inset values to contentPadding, as the Superheroes list does.
Mistake #6: animateContentSize after padding. The wrong code: Modifier.padding(16.dp).animateContentSize(). The problem: the padding is outside the animated area and jumps. The fix: put animateContentSize() before the padding.
Mistake #7: Forgetting by imports for animated values. The symptom: animateDpAsState returns a State<Dp> and using it as Dp fails. The fix: import androidx.compose.runtime.getValue and write val x by animateDpAsState(...), or use .value.
Mistake #8: Over-animating. The problem: long, bouncy effects on every element make the app feel slow and can bother users sensitive to motion. The fix: animate only to explain a change, keep it brief (around 150 to 400 ms) and respect that some users disable animations.
Mistake #9: Testing only in light mode. The symptom: an unreadable screen at night. The fix: add a dark @Preview for every screen and flip the emulator's dark theme switch before you finish.
Mistake #10: Theme not wrapping the content. The symptom: the app ignores your colours. The cause: setContent { SuperheroesApp() } without SuperheroesTheme { ... } around it, so the screen uses defaults. The fix: wrap the root composable in the theme, and in every preview too.
Question 1. What three things does MaterialTheme bundle together?
A colorScheme (colours by role), typography (the text styles) and shapes (corner shapes). Content inside the theme reads them via MaterialTheme.colorScheme, .typography and .shapes.
Question 2. Why must you pair primary with onPrimary?
onPrimary is the colour chosen for text and icons drawn on top of primary, with enough contrast to be readable. Using another colour risks unreadable content, especially in dark mode.
Question 3. What is dynamic colour and why do you check Build.VERSION.SDK_INT?
Dynamic colour builds the colour scheme from the user's wallpaper on Android 12 (API 31) and above, using dynamicLightColorScheme and dynamicDarkColorScheme. Those functions do not work on older versions, so you must check Build.VERSION.SDK_INT >= Build.VERSION_CODES.S and fall back to your own scheme.
Question 4. Which animation tool would you use to (a) smoothly resize a card when text is added, (b) fade a text block in and out, (c) change a colour between two states?
(a) Modifier.animateContentSize(), (b) AnimatedVisibility, (c) animateColorAsState.
Question 5. Why is animateContentSize() placed before padding in the modifier chain?
Modifiers apply in order. Putting animateContentSize() first means the size it animates includes the padding that comes after it, so the padded area changes smoothly instead of jumping.
Go deeper: official resources
- Android Basics with Compose, Unit 3: Material Design and animation: the official pathway this lesson follows.
- Jetpack Compose theming codelab: a deeper tour of Material 3 theming.
- Jetpack Compose animation codelab: more animation APIs, which lesson 41 builds on.
- Android Basics with Compose course overview: all units.
Project goal: This is the Project: Create a 30 Days App. Build a themed, scrolling app with 30 cards, one for each day of a 30-day challenge, where each card expands to show the day's task. It is a polished portfolio piece that uses lists, theming, shapes and animation together.
Requirements:
- Create a project
30 Dayswith packagecom.techbase.thirtydays. - Create a theme (
ThirtyDaysTheme) with your own light and darkColorScheme, a customTypographythat changes at least two styles, andShapeswith at least one custom corner. - Model each day with a data class
Day(val number: Int, val title: String, val task: String)and a list of 30 days about a goal you care about (learning to code, fitness, saving money, learning Yoruba, anything). - Show the days in a
LazyColumnof cards, with akeyfor each item. Each collapsed card showsDay Nand the title. - Tapping a card expands it with
animateContentSize, shows the task withAnimatedVisibility, and animates the card colour withanimateColorAsState. - Use
Scaffoldwith a top app bar titled30 Days of Coding, edge to edge with correct insets. - Provide light and dark
@Previewfunctions, and test on a real or emulated device in both modes. - Use plain
Stringfields as in the solution below, or (better) move all text tostrings.xmlwith@StringResids.
Stretch goal: Add a LazyListState and a floating action button that scrolls back to day 1 with animateScrollToItem(0) (this needs a coroutine scope; see lesson 28 or read ahead).
The solution keeps the thirty days in Kotlin strings to stay readable. In a published app you would move them to strings.xml, as in lessons 21 and the Superheroes app.
package com.techbase.thirtydays
data class Day(val number: Int, val title: String, val task: String)
val days: List<Day> = listOf(
Day(1, "Set up", "Install Android Studio and run the default app on a device."),
Day(2, "Say hello", "Print your name in the Kotlin Playground three different ways."),
Day(3, "Variables", "Store your age and city in variables and print a sentence."),
Day(4, "Functions", "Write a function that returns the area of a rectangle."),
Day(5, "Decisions", "Write an if expression that classifies a score as pass or fail."),
Day(6, "Loops", "Print the 7 times table with a for loop."),
Day(7, "Review", "Redo days 2 to 6 without looking at your notes."),
Day(8, "Lists", "Make a list of five foods and print each one in capitals."),
Day(9, "Maps", "Store three student names with their scores in a map."),
Day(10, "Lambdas", "Use filter to keep only scores above 50."),
Day(11, "Text composable", "Show your name in a large Text on screen."),
Day(12, "Column and Row", "Arrange three Text items in a Column and then a Row."),
Day(13, "Modifiers", "Add padding and a background to a Text."),
Day(14, "Review", "Rebuild the birthday card from memory."),
Day(15, "Images", "Show a picture with a content description."),
Day(16, "Buttons", "Make a button that increases a counter."),
Day(17, "State", "Keep two counters in separate state variables."),
Day(18, "Text fields", "Echo what the user types into a Text below."),
Day(19, "Lists on screen", "Show 10 items in a LazyColumn."),
Day(20, "Cards", "Wrap each list item in a Card."),
Day(21, "Review", "Build a small contact list with cards."),
Day(22, "Colours", "Create a light and dark colour scheme."),
Day(23, "Typography", "Change the headline style of your app."),
Day(24, "Shapes", "Give your cards a custom corner shape."),
Day(25, "Animation", "Animate a card growing when tapped."),
Day(26, "Testing", "Write one unit test for a function of your own."),
Day(27, "Debugging", "Put a breakpoint in a click handler and inspect a value."),
Day(28, "Icon", "Create an adaptive launcher icon."),
Day(29, "Polish", "Test in dark mode and with a large font size."),
Day(30, "Share", "Show your app to a friend and ask for one improvement.")
)
package com.techbase.thirtydays.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val LightColors = lightColorScheme(
primary = Color(0xFF3F51B5),
onPrimary = Color.White,
primaryContainer = Color(0xFFDDE1FF),
onPrimaryContainer = Color(0xFF00105C),
secondaryContainer = Color(0xFFFFE0B2),
onSecondaryContainer = Color(0xFF2B1700),
background = Color(0xFFFEFBFF),
onBackground = Color(0xFF1B1B1F),
surface = Color(0xFFFEFBFF),
onSurface = Color(0xFF1B1B1F)
)
private val DarkColors = darkColorScheme(
primary = Color(0xFFB9C3FF),
onPrimary = Color(0xFF08218A),
primaryContainer = Color(0xFF2A3990),
onPrimaryContainer = Color(0xFFDDE1FF),
secondaryContainer = Color(0xFF5C3F00),
onSecondaryContainer = Color(0xFFFFE0B2),
background = Color(0xFF1B1B1F),
onBackground = Color(0xFFE4E1E6),
surface = Color(0xFF1B1B1F),
onSurface = Color(0xFFE4E1E6)
)
private val AppTypography = Typography(
titleMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp),
labelMedium = TextStyle(fontWeight = FontWeight.Medium, fontSize = 13.sp)
)
private val AppShapes = Shapes(
medium = RoundedCornerShape(topStart = 4.dp, topEnd = 24.dp, bottomEnd = 4.dp, bottomStart = 24.dp)
)
@Composable
fun ThirtyDaysTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
package com.techbase.thirtydays
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.techbase.thirtydays.ui.theme.ThirtyDaysTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ThirtyDaysTheme {
ThirtyDaysApp()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThirtyDaysApp() {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
CenterAlignedTopAppBar(title = { Text("30 Days of Coding") })
}
) { innerPadding ->
LazyColumn(
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = innerPadding.calculateTopPadding() + 8.dp,
bottom = innerPadding.calculateBottomPadding() + 8.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(days, key = { it.number }) { day ->
DayCard(day)
}
}
}
}
@Composable
fun DayCard(day: Day, modifier: Modifier = Modifier) {
var expanded by rememberSaveable { mutableStateOf(false) }
val containerColor by animateColorAsState(
targetValue = if (expanded) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.primaryContainer
},
label = "dayCardColor"
)
Card(
modifier = modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
colors = CardDefaults.cardColors(containerColor = containerColor)
) {
Column(
modifier = Modifier
.animateContentSize()
.padding(16.dp)
) {
Text(text = "Day ${day.number}", style = MaterialTheme.typography.labelMedium)
Text(text = day.title, style = MaterialTheme.typography.titleMedium)
AnimatedVisibility(visible = expanded) {
Text(
text = day.task,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun ThirtyDaysLightPreview() {
ThirtyDaysTheme(darkTheme = false) { ThirtyDaysApp() }
}
@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
@Composable
fun ThirtyDaysDarkPreview() {
ThirtyDaysTheme(darkTheme = true) { ThirtyDaysApp() }
}
What the screen shows: a centred top bar, then 30 cards titled Day 1 / Set up and so on. Tapping a card changes its colour to the secondary container colour, grows it smoothly and reveals the day's task. Tapping again collapses it. The same screen works in light and dark mode because no colour appears outside the theme.
Why it works:
- The whole card is
clickable, which gives a larger touch target than a small button. rememberSaveableholds each card'sexpandedflag across rotation and across scrolling away and back.items(days, key = { it.number })gives each card a stable identity, andDay.numberis unique.- The text colours come from the card's default content colour, which Material 3 derives from the container colour, so contrast is maintained as the card changes colour. Check this by eye in both modes.
- The
Shapes.mediumcorner is used byCardautomatically, so no screen code mentions the corner shape.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 22 project”.
On github.com click New repository, name it android-lesson-22-material-theming-and-simple-animation-supe, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 22 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Material Design 3 and Themes
- Step-by-Step Build: Creating a Theme
- Step-by-Step Build: The Superheroes Practice App
- Step-by-Step Build: Simple Animation
- Step-by-Step Build: The Expandable Hero Card
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check