Buttons and click handling
How a Button takes two lambdas rather than one, why onClick = roll() is the classic beginner error, the five Material 3 button styles and when to use each, and how to make absolutely anything tappable.
Where a poster becomes an app
Everything so far has been a picture. Text, colours, layouts — beautiful, and utterly inert. A Button is the first thing you have written that lets the person holding the phone change something.
1Button(onClick = { score++ }) {
2 Text(text = "Roll")
3}That is a complete, styled, ripple-animated, accessible Material 3 button. Look closely at its shape, though, because it is stranger than it looks: there are two sets of braces, and they do completely different jobs.
A doorbell has two separate halves.
There is the button on the wall — a physical thing with a shape, a colour and a label saying which flat it belongs to. And there is the wire running from it to somewhere else in the building, where something actually happens.
The bell does not decide what happens. It has no idea. Its whole job is to be pressable and to send a signal down the wire.
In Compose, the content braces describe the button on the wall. The onClick braces are the wire. Keeping those two things separate is why the same Button can hold a word, a word and an icon, or a whole tiny layout, and still work identically.
Two lambdas, two jobs
1Button(
2 onClick = { score++ },
3 modifier = Modifier.fillMaxWidth()
4) {
5 Text(text = "Roll")
6}onClickis a of type() -> Unit: takes nothing, gives nothing back, runs when tapped. Compose stores it and calls it later.- The trailing braces are the
contentparameter — and it is a composable lambda. Whatever you put in there is drawn inside the button.
The second one is called a slot. Button does not have a text parameter, deliberately. Instead it leaves a hole and lets you fill it with any composables you like:
1Button(onClick = onRoll) {
2 Icon(
3 imageVector = Icons.Default.Refresh,
4 contentDescription = null
5 )
6 Spacer(Modifier.width(8.dp))
7 Text(text = "Roll")
8}The content of a Button is laid out as a Row, so those three things sit side by side and centred without you asking. Slots appear everywhere in Material 3 — cards, dialogs, top bars, the lot — and they are why the library is flexible without having forty parameters per composable.
The mistake everyone makes once
1Button(onClick = roll()) { // wrong
2 Text(text = "Roll")
3}That does not compile, and the error is worth reading slowly:
1e: Type mismatch: inferred type is Unit
2 but () -> Unit was expectedroll() — with brackets — calls the function immediately and hands Button whatever it returned, which is nothing. Button did not want a result. It wanted the function itself, to keep for later.
Three ways to write it correctly:
1Button(onClick = { roll() }) // a lambda calling it
2Button(onClick = ::roll) // a function reference
3Button(onClick = onRoll) // a lambda passed inIf it helps, read onClick = { roll() } as "when clicked, then roll", and onClick = roll() as "roll right now, and give the button the leftovers". Only one of those is a plan.
Five buttons, in order of loudness
Material 3 gives you a ladder of emphasis. Using the right rung is most of what makes a screen look professionally designed.
| Composable | Looks like | Use it for |
|---|---|---|
Button | Filled with your primary colour | The one main action on the screen |
FilledTonalButton | Filled with a soft container colour | A strong secondary action |
ElevatedButton | Pale with a shadow | A secondary action on a busy background |
OutlinedButton | Outline only | A secondary action, quieter still |
TextButton | Just the label | Cancel, Skip, Learn more |
They all take exactly the same parameters, so swapping one for another is a one-word edit.
The rule: one filled Button per screen. If everything shouts, nothing does.
Making it yours
1@Composable
2fun RollControls(
3 rolling: Boolean,
4 onRoll: () -> Unit,
5 onReset: () -> Unit
6) {
7 Row(
8 horizontalArrangement =
9 Arrangement.spacedBy(12.dp),
10 verticalAlignment = Alignment.CenterVertically
11 ) {
12 Button(
13 onClick = onRoll,
14 enabled = !rolling,
15 shape = RoundedCornerShape(18.dp),
16 contentPadding = PaddingValues(
17 horizontal = 30.dp,
18 vertical = 14.dp
19 ),
20 colors = ButtonDefaults.buttonColors(
21 containerColor = Color(0xFF5B3FD6),
22 contentColor = Color.White
23 )
24 ) {
25 Text(text = "Roll")
26 }
27 TextButton(onClick = onReset) {
28 Text(text = "Reset")
29 }
30 }
31}RollControls(rolling = false). One filled button, one text button.
Making anything tappable
Not everything that responds to a tap should look like a button. In Dice Duel, the die itself is the button:
1Canvas(
2 modifier = modifier
3 .size(size)
4 .shadow(14.dp, shape)
5 .clickable { onRoll() }
6) {
7 drawDie(value)
8}Modifier.clickable { } adds tap handling — plus a ripple and the right accessibility behaviour — to absolutely any composable. Remember the chain rule from Lesson 3.3: put .clickable early so the padding around your content is tappable too, not just the content itself.
For an icon on its own, prefer IconButton, which gives you a 48dp tap target for free — the minimum size a finger can reliably hit:
1IconButton(onClick = onDelete) {
2 Icon(
3 imageVector = Icons.Default.Delete,
4 contentDescription = "Delete note"
5 )
6}That contentDescription is not optional politeness. It is the only thing a screen reader has to announce, and without it the button is a silent mystery. Lesson 6.3 goes further.
A click handler is ordinary code, not composable code. You can change , call a plain function, launch a coroutine — but you cannot call a composable in there. onClick = { MyDialog() } will not compile.
To show a dialog, set state in the click and let the body react to it:
1var showDialog by remember { mutableStateOf(false) }
2
3Button(onClick = { showDialog = true }) {
4 Text(text = "Delete")
5}
6if (showDialog) {
7 ConfirmDialog(
8 onDismiss = { showDialog = false }
9 )
10}- Open ComposeLab and type the
RollControlscomposable from this lesson. - Call it from
setContentwith a little state to drive it:var rolling by remember { mutableStateOf(false) }, thenRollControls(rolling, onRoll = { rolling = true }, onReset = { rolling = false }). - Accept the imports. New ones:
androidx.compose.material3.Button,androidx.compose.material3.ButtonDefaults,androidx.compose.material3.TextButton,androidx.compose.foundation.layout.PaddingValuesandandroidx.compose.foundation.shape.RoundedCornerShape. - Tap Run. Tap Roll — it greys out. Tap Reset — it comes back. That is
enabledwired to state, which is the pattern behind every "please wait" button you have ever seen. - Now break it: change
onClick = onRolltoonClick = onRoll(). Try to build and read the type-mismatch error. Change it back. - Swap the word
ButtonforOutlinedButtonand Run. ThenTextButton. Same code, three levels of emphasis. - Add an
Iconand aSpacer(Modifier.width(8.dp))before theTextinside the button's braces, and Run. The slot took it without complaint.
onClick = roll() with brackets. That calls roll immediately and hands the button its result — nothing. The button wanted the function itself, to keep for later.onClick = { roll() }. Or pass a reference with no brackets: onClick = ::roll.Button(onClick = { ... }) and stopped. A Button has no default label — the content slot is required, and it is the trailing braces.Button(onClick = { ... }) { Text("Roll") }. Watch the closing bracket of the parameter list — the braces go after it, not inside it.onClick. Click handlers run long after composition has finished, so there is no composition for them to add anything to.if (showDialog) { ConfirmDialog(...) }.ButtonDefaults holds the standard colours and sizes for every button type, and it is a separate import from Button.import androidx.compose.material3.ButtonDefaults. Each button type has its own factory on it: buttonColors, textButtonColors, outlinedButtonColors.containerColor and left contentColor at its default, so the label is still the colour designed for the old background. White on white, or violet on violet.ButtonDefaults.buttonColors(containerColor = ..., contentColor = ...). Lesson 3.11 shows how theme roles pair them for you automatically.- A
Buttontakes two lambdas:onClick(the wire) and a trailing content slot (the label). onClick = roll()calls the function immediately and fails to compile.onClick = { roll() }is what you meant.- Five styles in order of loudness:
Button,FilledTonalButton,ElevatedButton,OutlinedButton,TextButton. One filled button per screen. enabled = falseis the right way to block an action, not anifinside the handler.contentPaddingsizes the inside of a button;Modifier.paddingspaces the outside.Modifier.clickable { }makes anything tappable;IconButtongives icons a finger-sized target and takes a .- Click handlers are ordinary code. Set there and let the body react.
- Next: text fields — reading what the user types, and why the field appears frozen if you forget one line.