Design actions people can understand
Buttons communicate both what will happen and how important that action is. Learn to choose the right Material 3 composable, expose a clear callback, and handle repeated taps while work is running.
Prerequisites: composable parameters, state, lambdas, and state hoisting. The worked example introduces a small coroutine only to simulate a delay; the course's coroutines lesson explains that machinery in depth. You will build a primary save action, compare icon actions, and verify disabled and busy states.
Choose emphasis before choosing color
| Composable | Typical purpose | Example |
|---|---|---|
| Button | Prominent action in a group | Save changes |
| FilledTonalButton | A supporting action with a filled surface | View details |
| OutlinedButton | An alternative or secondary action | Reset filters |
| TextButton | A lower-emphasis action | Cancel or Learn more |
| ElevatedButton | A button needing separation from its background | A contextual action over varied content |
| IconButton | Compact, recognizable action | Open search |
| FloatingActionButton | A prominent screen-level action | Create a note |
Think of a lesson screen: Continue is the next main step, Review notes is supporting, and Cancel exits the operation. Giving all three the same visual weight forces people to read every option equally. Start with the meaning of the action, then use theme defaults to express the hierarchy consistently.
A floating action button is appropriate when an action belongs to the screen as a whole. It is not a replacement for every row's button. An extended FAB adds a visible label when the icon alone would be ambiguous.
Separate an event from the UI description
onClick accepts a function to call later. A composable runs while Compose describes the interface, potentially many times; that is not when a save request should start. Write onClick = { save() }, or pass an existing function value. The curly braces defer the work until the action occurs.
// Focused excerpt inside a composable:
Button(
onClick = { onSave() },
enabled = canSave,
) {
Text("Save changes")
}
// Pass a callback; do not call it while describing the UI.
// Correct: Button(onClick = { onSave() }) { ... }
// Wrong: Button(onClick = onSave()) { ... }The trailing lambda is a content slot: it describes the button's children, commonly Text and an optional Icon. The component supplies layout and visual behavior around that content. Do not add another clickable to the Text; let the button own the action.
Understand the parameters you will actually use
| Parameter | Reason to change it |
|---|---|
| onClick | Report a user intention to the state owner. |
| enabled | Reflect whether this action is currently available. |
| modifier | Let the caller size and position the component. |
| contentPadding | Adjust internal label spacing when the design requires it. |
| shape / colors / elevation | Apply an intentional design-system variation using the matching defaults API. |
Pass external layout choices through modifier; use button parameters for the component's own styling. Setting only a background modifier does not redesign the component's internal surface. Use ButtonDefaults when changing colors, and check enabled and disabled appearances together.
Build a save action with a busy state
Add SaveLessonActions.kt to your app package with these imports. Call SaveLessonActions() inside the generated Activity's existing theme in setContent. The course's Compose project already has coroutine support; this demonstration never sends a request or writes data.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun SaveLessonActions(modifier: Modifier = Modifier) {
var saving by remember { mutableStateOf(false) }
var savedCount by rememberSaveable { mutableStateOf(0) }
val scope = rememberCoroutineScope()
fun save() {
if (saving) return
saving = true
scope.launch {
try {
delay(1000) // Simulated work only; no file or server is written.
savedCount += 1
} finally {
saving = false
}
}
}
Column(
modifier = modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Successful saves: $savedCount")
Button(onClick = { save() }, enabled = !saving) {
Text(if (saving) "Saving…" else "Save lesson")
}
OutlinedButton(
onClick = { savedCount = 0 },
enabled = !saving && savedCount > 0,
) { Text("Reset counter") }
Text("This demo simulates saving for one second.")
}
}Follow one tap: saving becomes true immediately, the label changes, and the actions disable. The coroutine finishes its delay and increments the count; finally releases the busy state. The guard in save() protects the operation even before a new frame displays the disabled button.
Tap quickly five times: the completed count should increase by one. Try Reset while saving: it is unavailable. Leave the composition during the delay: its remembered scope is canceled. This is correct for a short screen-owned demonstration; durable work needs an appropriate state owner and persistence strategy.
A disabled button is only one layer
Use icon actions without extra icon dependencies
Create the following vector in app/src/main/res/drawable/ic_add_lesson.xml. Android generates R.drawable.ic_add_lesson. The example uses this local asset, so no extended Material icons artifact is required.
<!-- app/src/main/res/drawable/ic_add_lesson.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000"
android:pathData="M11,5h2v6h6v2h-6v6h-2v-6H5v-2h6z" />
</vector>import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@Composable
fun AddActionVariants(
onAdd: () -> Unit,
modifier: Modifier = Modifier,
) {
// Comparison gallery: choose one appropriate variant on a real screen.
Column(modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
IconButton(onClick = onAdd) {
Icon(painterResource(R.drawable.ic_add_lesson), "Add lesson")
}
FloatingActionButton(onClick = onAdd) {
Icon(painterResource(R.drawable.ic_add_lesson), "Add lesson")
}
ExtendedFloatingActionButton(
onClick = onAdd,
icon = {
Icon(painterResource(R.drawable.ic_add_lesson), contentDescription = null)
},
text = { Text("Add lesson") },
)
}
}The two icon-only variants need an action description. The extended FAB already says Add lesson visibly, so its icon is decorative and uses contentDescription = null. In a localized app, obtain these labels from string resources.
Make the action usable beyond a tap
Use a verb and object such as Save lesson or Delete draft. A vague label like OK gives less context when read aloud or encountered outside the full visual layout. Material buttons already carry button semantics and keyboard interaction; preserve those defaults rather than rebuilding a button from a colored Box.
Keep practical touch targets at least 48 dp and leave space between neighboring actions. A 24 dp icon is artwork, not a recommended target size. Check large font settings, contrast in both themes, keyboard focus, and TalkBack. Avoid fixed button heights that cut off a translated or enlarged label.
Diagnose behavior before changing styling
| Symptom | Likely cause | Correction |
|---|---|---|
| Work starts when the screen appears | The operation runs in the composable body | Move the operation behind the event callback. |
| One tap triggers two handlers | Nested clickable children duplicate the action | Give one component ownership of the action. |
| Repeated saves reach the backend | Only visual disabling guards the operation | Guard the operation in its owner too. |
| The disabled label disappears | Custom colors ignore disabled contrast | Review all state colors through ButtonDefaults. |
Do not simulate a network request with Thread.sleep in a click callback. It blocks the UI thread, delaying the very busy indicator meant to reassure the user. Suspending work allows the interface to continue responding.
Practice: extract a reusable action
Extract the filled button into SaveAction(saving, onSave, modifier). The extracted composable must own no save counter and launch no coroutine. Its parent continues to control the operation and its busy state.
- Use the extracted action in the worked example without changing its behavior.
- Preview it once with saving false and once with saving true.
- Check the label, enabled state, and parent-supplied modifier in both configurations.
Compare a worked extraction
// Add the Button import if this is a separate file.
@Composable
fun SaveAction(
saving: Boolean,
onSave: () -> Unit,
modifier: Modifier = Modifier,
) {
Button(
onClick = onSave,
enabled = !saving,
modifier = modifier,
) {
Text(if (saving) "Saving…" else "Save lesson")
}
}
// Inside SaveLessonActions, replace only its filled Button:
SaveAction(saving = saving, onSave = { save() })
// Keep the saving guard and coroutine in the state owner.Review & quick check
Key takeaways
- Choose a button variant to communicate the action's role and emphasis.
- Pass a callback for later execution; composable evaluation must not submit work.
- The button owns its action, while the state owner controls availability and progress.
- Icon-only actions need meaningful descriptions; visible labels can make nearby icons decorative.
- Prevent duplicates in the operation owner and verify the busy, success, and failure states.
Quick check
1. Where should a reusable SaveAction decide how data reaches the server?
2. An extended FAB contains an add icon and visible Add lesson text. How should the icon be described?
3. Why does the example guard save() as well as disabling its Button?