Phase 2 · Building Interactive UIsModule 27~50 min read

Dialogs, Menus, Snackbars & Progress

Represent transient UI with state, separate confirmation from dismissal, anchor menus, launch snackbars from events and show truthful progress.

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

ComposablePurposeAvoid this use
AlertDialogA focused decision that interrupts the current taskConfirming every harmless tap
DropdownMenuA short set of choices attached to a triggerA full-screen catalog of hundreds of items
SnackbarHostBrief feedback with an optional small actionThe only place to explain a persistent validation error
ProgressIndicatorKnown completion fraction or an unknown waitA 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.

ConfirmationContract.kt
// 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") }
        },
    )
}
Focused state contract. The complete example below includes the required imports and an actual deletion event.

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.”

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

Do not call 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.

ProgressChoices.kt
// 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.
Focused examples: completed and total are Int values owned by the caller. The full workshop uses four real user-controlled checklist steps.

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.

FeedbackWorkshop.kt
// 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") }
            },
        )
    }
}
Complete composable and explicit imports. The draft is a local teaching Boolean, not a database record or network deletion.

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

SymptomLikely causeCorrection
Cancel deletes the recordDismiss and confirm share a destructive callbackDismiss only changes prompt visibility.
Menu remains behind the dialogSelection never cleared expandedClose the menu before opening confirmation.
Snackbar never appearsA state object exists without a displayed hostConnect the same remembered state to Scaffold's SnackbarHost.
Messages repeat after unrelated changesAn unconditional launch runs during compositionRequest event feedback from the event callback.

Practice & worked solution

  1. Test outside-tap dismissal, Back, Cancel, confirm, Undo and timeout. Write the expected draft state for each path.
  2. Add a Reset checks action and a persistent “All checks complete” message at four of four.
  3. 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.

FeedbackWorkshop-status.kt
// 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.
Patch to the complete example. A polite live region announces status updates without requesting immediate interruption.

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.