Composables and modifiers
Think of ordering a coffee. You don’t operate the machine; you say “a large latte with oat milk”, and the barista makes it. If you change your mind, you describe the new drink.
Jetpack Compose is declarativeDeclarative UIA UI style where you describe what the screen should look like for the current data, and the framework updates the screen when the data changes.Open in glossary → in the same way. You write functions that describe what the screen should show for the current data, and Compose builds it. When the data changes, you don’t find a TextView and call setText: Compose calls your function again (recompositionRecompositionJetpack Compose running a composable function again because state it reads has changed.Open in glossary →) and updates what changed.
A @Composable function doesn’t return UI; it emits nodes into the composition while it runs. The Compose compiler plugin rewrites it to take a hidden Composer, wraps its body in groups recorded in the slot table, and makes it restartable: when state it read changes, only that function’s scope is re-executed. With strong skipping (the default since Kotlin 2.0.20), a composable whose arguments are equal to last time is skipped.
So composables must be fast, idempotent and free of side effects: they may run on every frame of an animation, in any order, or be skipped entirely.
Your first composable
@Composable
fun Greeting(name: String) {
Text(
text = "Hello, $name!",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
Greeting(name = "Ana")
}Line by line:
@Composablemarks a function that describes UI. Composables are named with a capital letter, like classes, because they act like UI elements.Text(…)is a composable from the Material library. Calling it puts a text on the screen; it doesn’t return anything.MaterialTheme.typographyandcolorSchemetake styles from your app’s theme, so the text follows dark mode and the user’s font size.@Previewshows the composable in Android Studio’s preview pane, without running the app.
Modifiers: applied from the outside in
Size, padding, background and click handling go in a Modifier chain. Each call wraps everything that comes after it:
// Blue fills the padding too: the background is outside, the padding inside it
Text(
text = "Continue",
modifier = Modifier
.background(Color.Blue)
.padding(16.dp),
)
// Blue only behind the text: the padding is outside the blue
Text(
text = "Continue",
modifier = Modifier
.padding(16.dp)
.background(Color.Blue),
)Read the chain from the top, as layers from the outside in: in the first example the outer layer is a blue background, and inside it there’s 16 dp of padding around the text. In the second, the outer layer is the padding, and only what’s inside it is painted blue.
The same goes for taps. Modifier.clickable { }.padding(16.dp) makes the padding part of the button; Modifier.padding(16.dp).clickable { } leaves the padding outside, where taps do nothing.
Common composables and modifiers
| Composable | What it shows |
|---|---|
Text("Hi") | Text |
Icon(Icons.Filled.Favorite, contentDescription = "Like") | A Material icon |
Image(painterResource(R.drawable.logo), contentDescription = null) | An image from your resources |
Button(onClick = { … }) { Text("Save") } | A button; the lambda runs on tap |
Spacer(Modifier.height(8.dp)) | Empty space |
| Modifier | Effect |
|---|---|
.padding(16.dp), .padding(horizontal = 24.dp) | Space around the content |
.size(48.dp), .fillMaxWidth() | A fixed size, or all the width offered |
.background(Color.Yellow, RoundedCornerShape(8.dp)) | Paint behind the content, optionally in a shape |
.clip(CircleShape) | Cut the content to a shape |
.clickable { … } | React to taps, with a ripple |
By convention, every composable you write takes a modifier: Modifier = Modifier parameter and applies it to its outermost element, so callers can add padding or a size without changing your code.
Predict the output
How big is the yellow area?
Text(
text = "Hi",
modifier = Modifier
.size(200.dp, 100.dp)
.background(Color.Yellow),
)The chain starts with .size(200.dp, 100.dp), so the outer layer is a 200 × 100 box, and .background inside it paints all of that box. The text sits in the top-left corner (a Text doesn't center itself in extra space). Put .background first and .size second and the result is the same, because both are outside the text; to color just the words, the background would have to come after something that shrinks the area, like .wrapContentSize().
What a modifier chain really is
Modifier is an immutable, ordered list of elements; then (and every extension like padding) appends to it. At layout time the chain becomes a linked list of Modifier.Nodes around the layout node: the first modifier is the outermost. Layout modifiers (padding, size) transform constraints on the way in and sizes on the way out; draw modifiers (background, clip) draw what’s inside them; pointer modifiers (clickable) get the bounds of everything after them.
That ordering explains the classic reviews:
clickablebeforepaddinggives the larger touch target and a ripple that covers the padding. After it, the touch target is only the content.clipmust come beforebackgroundto round the background, and beforeclickableto clip the ripple.- Never put a fresh
Modifierobject in a remembered default. Passmodifierthrough as the first element of the root’s chain (modifier.padding(…)), notModifier.padding(…).then(modifier), so callers’ size and padding wrap yours.
Deep diveWhy Modifier.Node replaced composed {}Interview favorite
Older custom modifiers used Modifier.composed { … }, which ran a composable lambda per usage to create state. That allocation happened during composition, made modifiers impossible to compare, and broke skipping.
Modifier.Node (stable since Compose 1.5) moved the work to long-lived nodes: a ModifierNodeElement is a cheap, comparable data class created in composition, and the node it creates is updated in place instead of rebuilt. Built-in modifiers like clickable, padding and background were migrated, which is a large part of why Compose 1.5+ is noticeably faster on long lists.
- Composables emit into the composition; the compiler makes them restartable and skippable. No side effects in the body; it may run often, in any order, or not at all.
- A
Modifierchain is applied outside in: the first element is the outermost.background().padding()paints the padding;padding().background()doesn’t. clickablebeforepaddingfor a bigger touch target;clipbeforebackgroundandclickable.- Every public composable takes
modifier: Modifier = Modifierand applies it first on its root.
Check this part
Which chain gives a blue background that includes the padding?
What happens when the data a composable shows changes?
You want the whole padded area of a row to respond to taps. Which order?
Why must composables be free of side effects?