Goals & prerequisites
Good feedback answers a concrete question: what can I choose, what needs my decision, what happened, or how much work remains? Compose gives each answer a different component. The professional part is choosing the right one and giving it an explicit state owner.
Complete State Hoisting and Scaffold & App Bars first. You should recognize state delegates and event lambdas. This lesson introduces one small coroutine use for snackbar presentation; the coroutines lesson explains suspension more fully.
- Separate dismissing a prompt from confirming its action.
- Anchor a menu and close it after selection.
- Display a snackbar from an event and react to Undo.
- Choose meaningful determinate or indeterminate progress.
Choose the right feedback
| Composable | Purpose | Avoid this use |
|---|---|---|
AlertDialog | A focused decision that interrupts the current task | Confirming every harmless tap |
DropdownMenu | A short set of choices attached to a trigger | A full-screen catalog of hundreds of items |
SnackbarHost | Brief feedback with an optional small action | The only place to explain a persistent validation error |
ProgressIndicator | Known completion fraction or an unknown wait | A made-up percentage unrelated to actual work |
These components present your state; they do not decide whether deletion succeeded or a download completed. Keep business results explicit so the screen can show the truth even after the temporary message disappears.
Dialog state & confirmation
A Boolean controls whether an AlertDialog belongs in the composition. Setting it to false removes the dialog. onDismissRequest handles dismissal requests such as an outside tap or Back with the default dialog properties. It does not mean the user confirmed the operation.
// State belongs to the caller. This fragment is inside a @Composable function.
var confirming by rememberSaveable { mutableStateOf(false) }
TextButton(onClick = { confirming = true }) { Text("Delete draft") }
if (confirming) {
AlertDialog(
onDismissRequest = { confirming = false },
title = { Text("Delete draft?") },
text = { Text("You can undo this change from the snackbar.") },
confirmButton = {
TextButton(onClick = {
confirming = false
// Invoke the explicit delete event here.
}) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { confirming = false }) { Text("Cancel") }
},
)
}Keep the destructive event inside the confirm callback. Cancel and dismissal should only close the prompt. Specific labels such as “Delete” and “Cancel” let the user predict the result better than ambiguous labels such as “OK.”
Anchor and dismiss menus
Place the trigger and DropdownMenu in the same wrapping Box. The popup uses its parent layout as an anchor when positioning itself. A distant, screen-sized wrapper can place the popup somewhere surprising even if expanded is correct.
Use one state value for visibility. Set it to false in onDismissRequest and when a menu item is selected. Opening a confirmation prompt should close the menu first, so the user sees one decision surface at a time. A menu is a choice list; it does not replace a navigation back stack.
Snackbars from events
Remember a SnackbarHostState, display its SnackbarHost in Scaffold, and use rememberCoroutineScope to call the suspending showSnackbar from an event. The result distinguishes an action click from ordinary dismissal. Multiple calls can queue; avoid generating repeated messages for the same unchanged condition.
Composition is not an event
scope.launch unconditionally in a composable body to show feedback. Recomposition can repeat that launch. For this workshop, deletion is a user event, so its callback owns the snackbar request. Later, use carefully keyed effects when presentation must respond to external state.Truthful progress
Determinate progress needs a known fraction from zero to one. Use floating-point division: integer division would leave three of four checks at zero. The modern Material 3 overload takes a lambda, such as progress = { completed / 4f }. Without a meaningful total, use the indeterminate overload and describe the activity.
// Focused examples inside a @Composable function:
val fraction = if (total > 0) completed.toFloat() / total else 0f
LinearProgressIndicator(progress = { fraction.coerceIn(0f, 1f) })
// When the amount of remaining work is unknown, omit progress:
CircularProgressIndicator()
Text("Loading lessons…")
// Show this loading pair only while the actual loading state is true.
// Import CircularProgressIndicator from androidx.compose.material3.Build a feedback workshop
Create FeedbackWorkshop.kt beside MainActivity, keep its package declaration and add the code below. Call FeedbackWorkshop() inside your generated theme in setContent. It uses your Compose Material 3 template and its coroutine dependency; no assets or resource files are required. Move the teaching strings to resources when adapting the example.
// Keep your generated package declaration above these imports.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.launch
@Composable
fun FeedbackWorkshop(modifier: Modifier = Modifier) {
var hasDraft by rememberSaveable { mutableStateOf(true) }
var confirming by rememberSaveable { mutableStateOf(false) }
var menuOpen by remember { mutableStateOf(false) }
var completed by rememberSaveable { mutableIntStateOf(0) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) },
) { contentPadding ->
Column(
modifier = Modifier
.padding(contentPadding)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Feedback workshop", style = MaterialTheme.typography.headlineSmall)
Text(if (hasDraft) "A sample draft is available." else "The sample draft was removed.")
Box {
TextButton(onClick = { menuOpen = true }, enabled = hasDraft) {
Text("Draft actions")
}
DropdownMenu(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
text = { Text("Delete draft") },
onClick = {
menuOpen = false
confirming = true
},
)
}
}
TextButton(onClick = { hasDraft = true }, enabled = !hasDraft) {
Text("Restore sample draft")
}
Text("Checks completed: ${completed} of 4")
LinearProgressIndicator(
progress = { completed / 4f },
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = { completed = (completed + 1).coerceAtMost(4) }, enabled = completed < 4) {
Text("Mark next check complete")
}
}
}
if (confirming) {
AlertDialog(
onDismissRequest = { confirming = false },
title = { Text("Delete draft?") },
text = { Text("This demonstration removes a local sample. Undo is available briefly.") },
dismissButton = {
TextButton(onClick = { confirming = false }) { Text("Cancel") }
},
confirmButton = {
TextButton(onClick = {
confirming = false
hasDraft = false
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "Sample draft deleted",
actionLabel = "Undo",
withDismissAction = true,
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) hasDraft = true
}
}) { Text("Delete") }
},
)
}
}Open Draft actions, choose Delete draft, then cancel: the draft remains. Repeat and confirm: the status changes immediately and Undo briefly restores the sample. Mark four checks complete; the bar advances through 0%, 25%, 50%, 75% and 100%. No timer pretends to perform background work.
Accessibility & lifecycle
Read the dialog title, explanation and action labels with TalkBack. Confirm focus enters the prompt and returns sensibly after dismissal. Keep labels understandable without relying on red coloring. A snackbar can disappear, so keep the lasting result visible in the screen's state text too.
The remembered scope is canceled when its composable leaves composition. Do not put durable work that must outlive the screen inside that scope. This local Undo is sufficient for a sample Boolean; production deletion needs a data owner and recovery policy. Small saveable values can survive Activity recreation, but a pending snackbar and its coroutine are not durable business state.
Diagnose common mistakes
| Symptom | Likely cause | Correction |
|---|---|---|
| Cancel deletes the record | Dismiss and confirm share a destructive callback | Dismiss only changes prompt visibility. |
| Menu remains behind the dialog | Selection never cleared expanded | Close the menu before opening confirmation. |
| Snackbar never appears | A state object exists without a displayed host | Connect the same remembered state to Scaffold's SnackbarHost. |
| Messages repeat after unrelated changes | An unconditional launch runs during composition | Request event feedback from the event callback. |
Practice & worked solution
- Test outside-tap dismissal, Back, Cancel, confirm, Undo and timeout. Write the expected draft state for each path.
- Add a Reset checks action and a persistent “All checks complete” message at four of four.
- Use TalkBack and a large font setting. Ensure the final status remains reachable after the snackbar disappears.
Acceptance checks: dismissal never deletes, the menu closes after selection, Undo restores the sample, progress stays within bounds, and Reset returns it to zero. Announce discrete user-triggered status changes politely; avoid announcing every animation frame.
Reveal the reset and status solution
The same completed count drives the bar, status and enabled actions. One source of truth prevents a full bar paired with a stale label.
// Add these imports:
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
// Replace the plain completed-count Text with this persistent status:
Text(
text = if (completed == 4) "All checks complete" else "Checks completed: ${completed} of 4",
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
)
// Add this button beside the existing progress action:
TextButton(onClick = { completed = 0 }, enabled = completed > 0) {
Text("Reset checks")
}
// Announce this user-triggered status change, not every animation frame.Quick check & references
Key takeaways
- A dialog's dismissal path closes the prompt; its confirm path owns the action.
- Anchor a menu near its trigger and close it after selection.
- A snackbar needs a displayed host, remembered state and an event-driven coroutine.
- Show measured progress when known, and an indeterminate wait when it is not.
Quick check
1. What should onDismissRequest do for a delete confirmation?
2. Why launch showSnackbar from the delete callback in this example?
3. A task has completed three of four checks. Which value represents its progress?
Continue with the official guides to dialogs, menus, snackbars, progress indicators, and effect and coroutine lifetimes.