Pocket Studio Academy
HomePart 33.12

Images, icons and shapes

Full course10 min read·4 questions

Icon, Image and Canvas are three different answers to "put a picture here", and picking the wrong one is why some apps are 40 MB. Plus the shapes that clip everything, and the one parameter that decides whether a screen reader can use your app.

Pocket Notes contains one picture file

Open the finished Pocket Notes project and look in res/drawable. There is a single file in there, and it is the launcher icon.

Yet the app has a plus symbol on its button, a pencil on its empty screen, a magnifying glass that appears when a search finds nothing, and rounded cards throughout. None of those is a picture. They are all drawn, at the moment they are needed, at exactly the size they are needed.

That is not a clever trick — it is the normal way to build an Android app, and it is most of the reason a well-built app is a few megabytes rather than forty.

Think of it like this

Suppose you need a tree on a poster. You have three options in the room.

A photograph you glue on. It is exactly what it is: full colour, fixed size, and if you stretch it to fit a taller space the tree gets fat.

A rubber stamp of a tree. One shape, no colours of its own — it comes out in whatever ink you press it into, and a bigger stamp block gives a bigger tree with no loss of crispness.

A pen, and you draw the tree yourself. Total freedom, more work, and you can draw a tree nobody has ever stamped or photographed.

Compose gives you all three. Icon is the stamp, Image is the photograph, and is the pen. Nearly every mistake in this area is somebody reaching for the photograph when they wanted the stamp.

Icon — the stamp

kotlin
1Icon(
2    imageVector = Icons.Default.Add,
3    contentDescription = "New note"
4)

That is the plus on Pocket Notes' button. Three things about it are worth knowing:

  • It is an — a shape described as lines and curves, so it is sharp at any size and weighs almost nothing.
  • It has no colour of its own. Icon paints the whole thing in one tint, and unless you say otherwise that tint is the current content colour — which, inside a Surface or a Button, is already the correct on colour from Lesson 3.11. Put an icon on a primary button and it comes out onPrimary with no code.
  • It defaults to 24dp square, which is the Material standard. Change it with Modifier.size(44.dp).

The icons themselves come from , in five styles:

StyleWritten as
Filled (the default)Icons.Default.Add or Icons.Filled.Add
OutlinedIcons.Outlined.Add
RoundedIcons.Rounded.Add
SharpIcons.Sharp.Add
Two-toneIcons.TwoTone.Add

Pick one style for a whole app. Mixed styles look like two people built it.

Why your icon is unresolved

Material 3 ships with about forty icons built in — Add, Delete, Search, Menu, Close and the other everyday ones. Everything else lives in a separate library:

app/build.gradle.ktskts
1implementation(
2    libs.androidx.material.icons.extended
3)

Pocket Notes has that line, because Icons.Outlined.EditNote and Icons.Outlined.SearchOff are not in the core set. Dice Duel does not, because it needs none.

The extended library holds several thousand icons. In a strips the ones you never mention, so the shipped app pays for what it uses — but your debug APK does grow, and the build gets a little slower. Add it when you need it, not by habit.

contentDescription is not optional

The second parameter of every Icon and every Image is the one that decides whether somebody using can use your app at all.

The rule is one question: if the picture vanished, would information be lost?

kotlin
1// Yes — the button has no other label.
2Icon(
3    imageVector = Icons.Default.Add,
4    contentDescription = "New note"
5)
6
7// No — the word "Roll" is right next to it.
8Icon(
9    imageVector = Icons.Default.Refresh,
10    contentDescription = null
11)

null is a real answer, not a cop-out. It tells Android this is decoration, skip it, which is better than announcing "image" for the fiftieth time. What is never acceptable is describing the picture instead of its purpose: "plus icon" tells a blind user nothing. "New note" tells them everything.

Never include the word "button" or "icon" — the screen reader already says that part.

Image — the photograph

For an actual picture, from res/drawable:

kotlin
1Image(
2    painter = painterResource(R.drawable.dice_hero),
3    contentDescription = null,
4    contentScale = ContentScale.Crop,
5    modifier = Modifier
6        .fillMaxWidth()
7        .height(180.dp)
8        .clip(RoundedCornerShape(16.dp))
9)

turns a drawable — a PNG, a JPEG or a — into a , which is the thing Image knows how to draw. R.drawable.dice_hero is the same generated R class from Lesson 2.4, so a misspelled name is a compile error rather than a blank space.

is the parameter people forget, and it is the one that decides whether your photo looks professional or stretched:

ValueWhat it does
CropFill the space, trim whatever overflows. Almost always what you want
FitFit the whole picture inside, leaving gaps
FillBoundsStretch to fill exactly. This is the squashed-face one
InsideLike Fit, but never scales a small image up

FillBounds is the default-looking answer and is almost never right, because it ignores the picture's . If a photo of a person looks slightly wrong and you cannot say why, this is it.

Shapes

A shape in Compose is just a rule for cutting a corner. Three cover nearly everything:

kotlin
1CircleShape
2RoundedCornerShape(16.dp)
3CutCornerShape(12.dp)

is a full pill — on a square it is a circle, on a wide box it is a lozenge. takes one radius for all four corners, or one each: RoundedCornerShape(topStart = 20.dp, bottomEnd = 20.dp). slices the corner off flat instead of curving it.

Shapes turn up in four places, and they mean the same thing in all of them:

  • Modifier.clip(shape) — trim whatever is drawn after it in the chain (Lesson 3.3).
  • Modifier.border(2.dp, color, shape) — draw an outline on that curve.
  • Surface(shape = ...) and Card(shape = ...) — the Material way.
  • MaterialTheme.shapes.medium — the shape scale from your theme, so cards across an app agree.

Surface — colour, shape and content colour together

is the Material container. Hand it a colour and a shape and it does three things: paints the background, clips its contents to the shape, and sets the content colour for everything inside so icons and text come out in the right on colour automatically.

Here is Pocket Notes' empty screen, which is nothing but a Surface, an Icon and two Texts.

EmptyNotes.ktkotlin
1@Composable
2private fun EmptyMessage(
3    icon: ImageVector,
4    title: String,
5    body: String
6) {
7    val type = MaterialTheme.typography
8    val colors = MaterialTheme.colorScheme
9
10    Column(
11        modifier = Modifier
12            .fillMaxSize()
13            .padding(horizontal = 40.dp),
14        horizontalAlignment =
15            Alignment.CenterHorizontally,
16        verticalArrangement = Arrangement.Center
17    ) {
18        Surface(
19            shape = CircleShape,
20            color = colors.primaryContainer,
21            modifier = Modifier.size(96.dp)
22        ) {
23            Box(
24                contentAlignment = Alignment.Center
25            ) {
26                Icon(
27                    imageVector = icon,
28                    contentDescription = null,
29                    tint = colors.onPrimaryContainer,
30                    modifier = Modifier.size(44.dp)
31                )
32            }
33        }
34
35        Spacer(Modifier.height(24.dp))
36        Text(
37            text = title,
38            style = type.headlineSmall,
39            color = colors.onSurface
40        )
41        Spacer(Modifier.height(8.dp))
42        Text(
43            text = body,
44            style = type.bodyMedium,
45            color = colors.onSurfaceVariant,
46            textAlign = TextAlign.Center
47        )
48    }
49}
9:41▲ ▮
No notes yet
Tap the + button to write your first one. Everything you type is saved on this phone, and only on this phone.

EmptyNotes() in light mode. One Surface, one Icon, two Texts.

The pen: drawing it yourself

Sometimes the picture you need does not exist as an icon and would be silly as a photograph — a die showing whatever number was just rolled, for instance. Six PNGs would work, and would be six files that are wrong on some screen densities.

Dice Duel draws its die instead:

kotlin
1Canvas(modifier = Modifier.size(160.dp)) {
2    val side = size.minDimension
3    drawRoundRect(
4        color = Color(0xFFFDFBFF),
5        size = Size(side, side),
6        cornerRadius = CornerRadius(
7            side * 0.22f,
8            side * 0.22f
9        )
10    )
11    drawCircle(
12        color = Color(0xFF241A4D),
13        radius = side * 0.085f,
14        center = Offset(side / 2f, side / 2f)
15    )
16}

That is a die showing one — the real DieFace is the same two calls with a loop over pip positions around them. Inside a Canvas you are in a , which hands you size (the exact space you were given, in pixels) and a set of draw... functions. Every number is written as a fraction of side rather than a fixed measurement, which is why the same code produces a sharp 88dp die in the win banner and a 172dp one on the board.

Canvas gets a proper treatment in Part 5, where Focus Flow's ring dial is built on it.

So which one?

  • The shape is a symbol with one colourIcon, from Material Icons.
  • It is a photograph or a multi-coloured graphicImage with painterResource.
  • It changes with your data, or does not exist as a file → Canvas.

If you are about to add a PNG of a symbol, stop and check the icon set first. It is almost always there, it will be sharper, and it will follow your theme colours for free.

Try it in Pocket Studio
  1. Open ComposeLab and open MainActivity.kt.
  2. Inside setContent, add an Icon with imageVector = Icons.Default.Add and contentDescription = "New note". Accept the imports for androidx.compose.material.icons.Icons and androidx.compose.material.icons.filled.Add.
  3. Tap Run. A small black plus, 24dp.
  4. Add modifier = Modifier.size(64.dp) and Run again. Bigger, and still perfectly sharp — that is the vector doing its job.
  5. Wrap the Icon in a Surface with shape = CircleShape and color = MaterialTheme.colorScheme.primaryContainer, plus modifier = Modifier.size(96.dp). Run. The icon is in the top-left corner of the circle.
  6. Put a Box(contentAlignment = Alignment.Center) between the Surface and the Icon. Run. Centred. Remember that pairing — you will need it constantly.
  7. Delete the tint if you added one, and Run. The icon comes out in onPrimaryContainer without being told, because Surface set the content colour.
  8. Change CircleShape to CutCornerShape(20.dp) and Run. Same code, an octagon.
  9. Now try Icons.Outlined.EditNote. It will not resolve — that icon lives in the extended library, and ComposeLab does not have it. That error is the third one in the Error Doctor below, and now you have seen it on purpose.
Error Doctor5 common errors
e: Unresolved reference: Icons
MeansThe icon set has its own import, and it is in the material package rather than material3 — the icons are shared between both versions of Material.
FixAdd import androidx.compose.material.icons.Icons, plus one import per icon you use: androidx.compose.material.icons.filled.Add for Icons.Default.Add. The Icon composable itself is separate again: androidx.compose.material3.Icon.
e: Unresolved reference: EditNote
MeansThat icon is not in the small core set that ships with Material 3. Only about forty everyday icons are; the other few thousand are in a separate library.
FixAdd implementation(libs.androidx.material.icons.extended) to app/build.gradle.kts, tap Sync, then import androidx.compose.material.icons.outlined.EditNote. If the library is already there, check the spelling and the style — Icons.Outlined.EditNote and Icons.Default.EditNote are different imports.
e: Unresolved reference: painterResource
MeansMissing import. It lives with the other resource helpers, not with the layout or graphics ones.
FixAdd import androidx.compose.ui.res.painterResource. It sits next to stringResource in androidx.compose.ui.res.
error: invalid file name: must contain only lowercase letters, digits, or underscore
MeansA file in res/drawable has a capital letter, a dash or a space in its name. Resource names become Kotlin identifiers in the R class, so the rules are strict.
FixRename it to lowercase with underscores: dice_hero.png, never DiceHero.png or dice-hero.png. Names must also start with a letter — 2x_logo.png fails too.
My multi-coloured logo appears as a solid block of one colour
MeansYou drew it with Icon rather than Image. Icon exists to tint a single-colour symbol, so it repaints every pixel in the tint colour — which is exactly what it promises to do.
FixUse Image(painter = painterResource(R.drawable.logo), contentDescription = ...). Keep Icon for one-colour symbols that should follow the theme.
Recap
  • Three ways to get a picture on screen: Icon for one-colour symbols, Image for real pictures, for anything you draw yourself.
  • Icon takes an , defaults to 24dp, and paints everything in one — inside a Surface that tint is already the right on colour.
  • The core icon set is small. extended adds thousands, at the cost of a dependency; add it when you need it.
  • contentDescription describes the purpose, not the drawing. Use null when the picture is decoration, and never say "button" or "icon".
  • loads a drawable; decides how it fills its box. Crop is usually right, FillBounds squashes.
  • , RoundedCornerShape and are used identically by clip, border, Surface and Card.
  • paints, clips and sets the content colour in one composable. Wrap its child in a Box with Alignment.Center to centre it.
  • Next: animation — how to make the die actually roll, using the state you already have and four lines of code.