Phase 2 · Building Interactive UIsModule 19~40 min read

Button, IconButton & FAB: Actions That Work

Choose action emphasis, use content slots, model enabled and busy states, and make labeled buttons, icon actions and floating actions accessible.

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

ComposableTypical purposeExample
ButtonProminent action in a groupSave changes
FilledTonalButtonA supporting action with a filled surfaceView details
OutlinedButtonAn alternative or secondary actionReset filters
TextButtonA lower-emphasis actionCancel or Learn more
ElevatedButtonA button needing separation from its backgroundA contextual action over varied content
IconButtonCompact, recognizable actionOpen search
FloatingActionButtonA prominent screen-level actionCreate 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.

ButtonContract.kt
// 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()) { ... }
Focused example; onSave and canSave are values supplied by the caller.

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

ParameterReason to change it
onClickReport a user intention to the state owner.
enabledReflect whether this action is currently available.
modifierLet the caller size and position the component.
contentPaddingAdjust internal label spacing when the design requires it.
shape / colors / elevationApply 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.

SaveLessonActions.kt
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.")
    }
}
Complete composable. The visible count represents completed simulations, not durable saved lessons.

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

For real asynchronous actions, the ViewModel or operation owner must also prevent unintended duplicates. Handle failure and retry there, and use a repeat-safe request design where needed. A composable's Boolean is not a transaction guarantee.

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.

res/drawable/ic_add_lesson.xml
<!-- 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>
Create this resource before using the Kotlin comparison below.
AddActionVariants.kt
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") },
        )
    }
}
Place in your app package so R resolves. For a temporary preview, call AddActionVariants(onAdd = { }) under the theme; provide a real callback in a screen.

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

SymptomLikely causeCorrection
Work starts when the screen appearsThe operation runs in the composable bodyMove the operation behind the event callback.
One tap triggers two handlersNested clickable children duplicate the actionGive one component ownership of the action.
Repeated saves reach the backendOnly visual disabling guards the operationGuard the operation in its owner too.
The disabled label disappearsCustom colors ignore disabled contrastReview 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.

  1. Use the extracted action in the worked example without changing its behavior.
  2. Preview it once with saving false and once with saving true.
  3. Check the label, enabled state, and parent-supplied modifier in both configurations.
Compare a worked extraction
SaveAction.kt
// 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.
Uses the Compose and Material imports already present in SaveLessonActions.kt.

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?

Official references