contentScale and Modifier.size are answering different questions

Image sizing depends on the modifier constraints and contentScale together. Understanding which one crops, which one fits, and where the aspect ratio comes from resolves most image layout bugs in one pass.

5 min read
androidcomposekotlingraphics

Day 55 — contentScale and Modifier.size answer different questions

Day 55 of 100, opening the graphics pillar. Images are the most common source of "it looks wrong and I've tried every parameter" in a Compose codebase.

The symptom

An avatar that's an oval:

Image(
    painter = painterResource(R.drawable.avatar),
    contentDescription = null,
    modifier = Modifier.size(48.dp).clip(CircleShape),
)

The source image is 400×300. The composable is 48×48. The image stretches to fill both dimensions, so a rectangular photo becomes a squashed square, then gets clipped to a circle — an oval face in a round frame.

The neighbouring thumbnail has the opposite problem: it keeps its proportions and leaves grey bars down the sides.

Why the obvious fix fails

The obvious fix is to try contentScale values until one looks right:

Image(…, contentScale = ContentScale.Fit)     // now it letterboxes
Image(…, contentScale = ContentScale.Crop)    // now it fills — but is it right?

Cycling through the enum does eventually produce something acceptable, and it doesn't tell you why, so the next image with a different source aspect ratio starts the cycle again.

The two parameters answer different questions, and knowing which is which makes the choice deterministic.

The modifier decides the box; contentScale decides how the image fills it

The actual mechanism

Two independent decisions:

Modifier decides the box. size, fillMaxWidth, aspectRatio — the constraints from Day 21. This is the area the image occupies in the layout.

contentScale decides how the image fills that box. Given a source of one aspect ratio and a box of another, something has to give: crop the image, letterbox the box, or distort.

The default is ContentScale.Fit, which preserves aspect ratio and letterboxes. The squashed avatar happens with ContentScale.FillBounds, or when the box's aspect ratio happens to match nothing.

The values, by what they sacrifice:

Value Preserves ratio Fills the box Crops
Fit ✗ (letterboxes)
Crop
FillBounds ✗ (distorts)
FillWidth / FillHeight one axis maybe
Inside only if smaller
None maybe

For an avatar in a fixed square, the answer is always Crop:

Image(
    painter = painterResource(R.drawable.avatar),
    contentDescription = null,
    contentScale = ContentScale.Crop,
    modifier = Modifier.size(48.dp).clip(CircleShape),
)

Crop centres the image, scales it so the smaller dimension fills the box, and clips the overflow. Faces survive; proportions survive.

The rule that resolves most cases: fixed box, unknown source ratio → Crop. Known ratio you want to honour → aspectRatio on the modifier and Fit.

Where the aspect ratio should come from

For a feed of photos, letting each image dictate its own height produces the ragged layout Day 24 mentioned. Better to derive the box from data you have:

AsyncImage(
    model = photo.url,
    contentDescription = photo.caption,
    contentScale = ContentScale.Crop,
    modifier = Modifier
        .fillMaxWidth()
        .aspectRatio(photo.width.toFloat() / photo.height),
)

aspectRatio derives the height from the assigned width, so the box is known before the image loads — no reflow when it arrives, and no scroll jump. That requires the dimensions in your API response, which is worth asking for.

Without them, a fixed ratio (aspectRatio(16f / 9f)) plus Crop is the stable choice. Letting the image decide is what causes the jitter.

Async loading

Compose has no built-in network image loader; Coil is the common choice and is Compose-native:

AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(photo.url)
        .crossfade(true)
        .build(),
    contentDescription = photo.caption,
    contentScale = ContentScale.Crop,
    placeholder = painterResource(R.drawable.placeholder),
    error = painterResource(R.drawable.broken),
    modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
)

placeholder and error are the parameters most often left out, and they're the difference between a loading feed that looks considered and one that looks broken. Day 41's argument applies: the placeholder should occupy the same box as the final image, which aspectRatio guarantees.

SubcomposeAsyncImage gives you full composable slots for the loading and error states when a static painter isn't enough — at the cost of a subcomposition per image, so it's not the default choice inside a long list.

contentDescription is not optional

The parameter has no default, which is deliberate — you have to decide:

Image(…, contentDescription = "Profile photo of ${user.name}")  // meaningful
Image(…, contentDescription = null)                             // decorative

null is a real answer and means "this conveys nothing; skip it". A background texture, a divider ornament or an icon next to a text label that already says the same thing should all be null, because announcing them is noise.

What's wrong is a description that restates the obvious ("image", "photo") or duplicates adjacent text. The test: if you couldn't see the screen, would this sentence help? And if an image is the only content of a clickable row, its description is what the whole row announces — so it has to carry the action, not just the picture.

How to prove it

The sizing question is a preview with deliberately awkward sources:

@Preview
@Composable fun AvatarRatios() = Row {
    Avatar(R.drawable.wide_400x300)
    Avatar(R.drawable.tall_300x400)
    Avatar(R.drawable.square_400x400)
}

All three should render as identical circles with undistorted faces. Any that's an oval has the wrong contentScale; any that letterboxes has Fit where it needs Crop.

For the loading states, throttle the emulator's network and scroll a feed. Anything that shifts as images arrive has a box the image is deciding, not the layout.

Ask for the size you're going to draw

The single biggest image performance win has nothing to do with contentScale: don't decode a 4000×3000 photo to fill a 400×300 thumbnail.

Coil resolves the target size from the composable's constraints automatically, which is most of why using it beats a hand-rolled loader. Where it can't — an unbounded axis — tell it:

AsyncImage(
    model = ImageRequest.Builder(context)
        .data(photo.url)
        .size(400, 300)          // decode at this size
        .build(),
    …
)

Better still, ask the server for the right size. A CDN that takes ?w=400 returns fewer bytes over the network as well as fewer pixels in memory, and the saving compounds across a feed. Downloading a 3MB original to display it at thumbnail size is a cost paid three times — bandwidth, decode time and heap — and none of it is visible on a fast connection with a warm cache, which is exactly the condition you develop under.

What this generalizes to

The reusable idea is separating the container decision from the content decision. The layout owns how much room there is; the content owns how it uses that room; and when the two disagree about aspect ratio, something must be sacrificed — and which thing is a choice you should make rather than discover.

CSS split the same problem into width/height and object-fit, with the same values under different names. Recognising it as two questions rather than one parameter to fiddle with is what makes it stop being trial and error.

Tomorrow, Day 56: ImageBitmap versus ImageVector — two representations, and picking the wrong one costs memory or fidelity.


Day 55 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Images in Compose.