Pantrix UI

Snackbar

Puts the snackbars of state on screen: short messages about what the app has just done, each with at most one action, in a neutral, primary or semantic tone, one at a time.

import com.pantrixui.components.overlay.snackbar.PantrixSnackbarHost

Examples

A message

The least there is to write: a message. The queue does not come here as a parameter; the scaffold provides it to every screen inside it through LocalPantrixSnackbars. show is a suspending function, so it is called in a coroutine. A snackbar without an action leaves by itself after four seconds.

The scope is this example's (rememberCoroutineScope): it is cancelled when the page is left, and takes the snackbar on screen with it. A snackbar that must stay even when the screen changes is asked for in a scope that outlives the screen. Outside a scaffold, where nothing provides the queue, LocalPantrixSnackbars.current is null and the button does nothing.

val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
PantrixButton(
    text = "Save draft",
    onClick = { scope.launch { snackbars?.show(message = "Draft saved", tagKey = "draft_saved") } },
    variant = ButtonVariant.Outline,
    tagKey = "save_draft"
)

Tones

The snackbar's six tones: each button asks the app's queue for a snackbar in that tone. tone picks the colour of one of the theme's roles, an icon in the four semantic tones, and how the snackbar is announced: Danger interrupts what a screen reader is saying, and the others are announced without interrupting. A snackbar is filled in every tone: from the role's color and onColor, not its pale container.

Those with an action stay until the action is taken or they are closed; those without one leave by themselves after four seconds. The snackbars of buttons pressed one after another come in turn, one at a time. How each one left is written under the buttons.

val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
var lastResult by remember { mutableStateOf<String?>(null) }
FlowRow(
    horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm),
    verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
) {
    SAMPLES.forEach { sample ->
        PantrixButton(
            text = sample.tone.name,
            onClick = {
                scope.launch {
                    val result = snackbars?.show(
                        message = sample.message,
                        actionLabel = sample.action,
                        tone = sample.tone,
                        tagKey = sample.tone.name.lowercase()
                    )
                    if (result != null) lastResult = "${sample.tone.name}: ${result.name}"
                }
            },
            variant = ButtonVariant.Outline,
            tagKey = "show_${sample.tone.name}"
        )
    }
}
lastResult?.let { result ->
    PantrixText(
        text = "Last snackbar → $result",
        style = PantrixTheme.typography.footnote,
        color = PantrixTheme.colors.textSecondary
    )
}

private class SnackbarSample(
    val tone: SnackbarTone,
    val message: String,
    val action: String?
)

private val SAMPLES = listOf(
    SnackbarSample(SnackbarTone.Neutral, "Conversation archived", "Undo"),
    SnackbarSample(SnackbarTone.Primary, "A new version is ready", "Update"),
    SnackbarSample(SnackbarTone.Info, "Sync pauses on mobile data", null),
    SnackbarSample(SnackbarTone.Success, "Changes saved", null),
    SnackbarSample(SnackbarTone.Warning, "Storage is almost full", "Manage"),
    SnackbarSample(SnackbarTone.Danger, "Upload failed", "Retry")
)

An action and its result

actionLabel gives the snackbar a single action. Once the snackbar has left, show returns how it left: SnackbarResult.ActionPerformed if the action was pressed; Dismissed if its time ran out, it was closed or the app dismissed it. Pressing "Undo" puts the conversation back in the inbox.

A snackbar with an action does not leave by itself: a few seconds are not enough for some users to reach the action. That is why its default duration is UntilDismissed, and it has a close button.

val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
var inbox by rememberSaveable { mutableIntStateOf(5) }
PantrixButton(
    text = "Archive a conversation",
    onClick = {
        inbox -= 1
        scope.launch {
            val result = snackbars?.show(
                message = "Conversation archived",
                actionLabel = "Undo",
                tagKey = "archived"
            )
            if (result == SnackbarResult.ActionPerformed) inbox += 1
        }
    },
    enabled = inbox > 0,
    variant = ButtonVariant.Outline,
    tagKey = "archive"
)
PantrixText(text = "Conversations in the inbox: $inbox", color = PantrixTheme.colors.textSecondary)

Duration and close button

duration says how long the snackbar stays when nobody acts on it: Short is four seconds, Long ten; UntilDismissed is until it is closed. The platform may make this longer for a user who needs more time. A snackbar that stays until it is closed has a close button; closeButton = true puts one on a short snackbar too.

Hold the pointer over the snackbar: its time waits, and starts again when the pointer moves away. It waits while keyboard focus is inside it too. Swiping the snackbar sideways sends it away without waiting for its time.

val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
FlowRow(
    horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm),
    verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
) {
    PantrixButton(
        text = "Short",
        onClick = {
            scope.launch {
                snackbars?.show(
                    message = "Link copied",
                    duration = SnackbarDuration.Short,
                    tagKey = "short"
                )
            }
        },
        variant = ButtonVariant.Outline,
        tagKey = "show_short"
    )
    PantrixButton(
        text = "Long",
        onClick = {
            scope.launch {
                snackbars?.show(
                    message = "Export complete: the file is in the Downloads folder",
                    duration = SnackbarDuration.Long,
                    tagKey = "long"
                )
            }
        },
        variant = ButtonVariant.Outline,
        tagKey = "show_long"
    )
    PantrixButton(
        text = "UntilDismissed",
        onClick = {
            scope.launch {
                snackbars?.show(
                    message = "Update downloaded: it will be installed on restart",
                    duration = SnackbarDuration.UntilDismissed,
                    tagKey = "until_dismissed"
                )
            }
        },
        variant = ButtonVariant.Outline,
        tagKey = "show_until_dismissed"
    )
    PantrixButton(
        text = "Short, with a close button",
        onClick = {
            scope.launch {
                snackbars?.show(message = "File moved to the bin", closeButton = true, tagKey = "with_close")
            }
        },
        variant = ButtonVariant.Outline,
        tagKey = "show_with_close"
    )
}

Snackbars that come in turn

Three snackbars are asked for one after another; they come on screen in the order they were asked for, one at a time. show suspends until its own turn comes. The app's single queue keeps the order: a snackbar that another example asks for waits in the same line.

"Cancel the rest" cancels this example's coroutines: when the coroutine that called show is cancelled, the snackbar on screen leaves, and those whose turn has not come are never shown.

val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
FlowRow(
    horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm),
    verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
) {
    PantrixButton(
        text = "Upload three files",
        onClick = {
            listOf("Report.pdf", "Budget.xlsx", "Presentation.key").forEach { file ->
                scope.launch {
                    snackbars?.show(message = "$file uploaded", tone = SnackbarTone.Success, tagKey = "uploaded")
                }
            }
        },
        variant = ButtonVariant.Outline,
        tagKey = "upload_three"
    )
    PantrixButton(
        text = "Cancel the rest",
        onClick = { scope.coroutineContext.cancelChildren() },
        variant = ButtonVariant.Text,
        tagKey = "cancel_uploads"
    )
}

A snackbar the app dismisses

Some snackbars are dismissed by the app, not the user: once the connection is back there is no point in saying "You're offline". PantrixSnackbarState.current is the snackbar on screen, or null; dismiss() takes it away, and show returns SnackbarResult.Dismissed. "Back online" is enabled only while this example's snackbar is on screen.

This snackbar stays until it is dismissed and has no close button (closeButton = false); swiping it sideways still sends it away.

val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
val onScreen = snackbars?.current
FlowRow(
    horizontalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm),
    verticalArrangement = Arrangement.spacedBy(PantrixTheme.spacing.sm)
) {
    PantrixButton(
        text = "Go offline",
        onClick = {
            scope.launch {
                snackbars?.show(
                    message = "You're offline: changes will sync when the connection is back",
                    duration = SnackbarDuration.UntilDismissed,
                    closeButton = false,
                    tone = SnackbarTone.Warning,
                    tagKey = "offline"
                )
            }
        },
        variant = ButtonVariant.Outline,
        tagKey = "go_offline"
    )
    PantrixButton(
        text = "Back online",
        onClick = { onScreen?.dismiss() },
        enabled = onScreen?.tagKey == "offline",
        variant = ButtonVariant.Outline,
        tagKey = "back_online"
    )
}

A colour given to the host

The colour is given not to the snackbar but to the host that shows it: PantrixSnackbarHost(state, colors = SnackbarColors(container = …, content = …)). The app's host stands at the root, so the switch's state is there too, in the app's settings; the host reads it. While it is on, every snackbar is drawn with the same fill; its icon and the way it is announced still come from its tone.

Flip the switch, then ask for a snackbar from here or from "Tones". The switch stands at the end away from the edge the snackbar appears at: below the button where the snackbar appears at the top (iOS), above it where it appears at the bottom.

val snackbars = LocalPantrixSnackbars.current
val scope = rememberCoroutineScope()
val snackbarsAtTheTop = PantrixTheme.style(SnackbarStyle).placement == SnackbarPlacement.Top
if (!snackbarsAtTheTop) SnackbarOwnColorsSwitch()
PantrixButton(
    text = "Finish the backup",
    onClick = {
        scope.launch {
            snackbars?.show(message = "Backup complete", tone = SnackbarTone.Success, tagKey = "backed_up")
        }
    },
    variant = ButtonVariant.Outline,
    tagKey = "back_up"
)
if (snackbarsAtTheTop) SnackbarOwnColorsSwitch()

@Composable
private fun SnackbarOwnColorsSwitch() {
    val settings = LocalDemoSettings.current
    PantrixSwitchButton(
        checked = settings.ownSnackbarColors,
        onCheckedChange = { settings.ownSnackbarColors = it },
        label = "In the app's own colour",
        supportingText = "When on, snackbars are filled with the colour given to their host, not with their tone's",
        tagKey = "own_snackbar_colors"
    )
}

A host of its own: placement, style and colour

There is usually a single host, at the root of the app. Here, to show what its parameters do, there is a second host in a box, with a queue of its own: rememberPantrixSnackbarState gives the queue, and PantrixSnackbarHost shows it. The host is the box's last child: it fills the space it is given and stands above the content.

style changes the placement and the geometry: placement picks one of four places, and maxWidth keeps the snackbar to 320dp. In a narrow box the snackbar fills the box; start, centre and end only differ in a wide one. colors paints every snackbar of this host: the fill is the app's own purple, and the icon is still the tone's.

The host stays inside the window's safe area. This box stands in the middle of the window, so it consumes those insets itself (consumeWindowInsets); a host at the root does not need to.

The snackbar the queue shows is its current (SnackbarData): the line under the box writes its message, and "Remove" closes it with dismiss(), without waiting out its time.

val snackbars = rememberPantrixSnackbarState()
val scope = rememberCoroutineScope()
val own = ownColors()
var placement by rememberSaveable { mutableStateOf(SnackbarPlacement.BottomCenter) }
PantrixSegmentButton(
    options = PLACEMENTS,
    selected = placement,
    onSelect = { placement = it },
    tagKey = "placement"
)
PantrixSurface(
    modifier = Modifier
        .fillMaxWidth()
        .height(220.dp)
        .consumeWindowInsets(WindowInsets.safeDrawing),
    border = PantrixTheme.colors.divider
) {
    PantrixButton(
        text = "Show in this box",
        onClick = {
            scope.launch {
                snackbars.show(message = "The box's own snackbar", tone = SnackbarTone.Success, tagKey = "boxed")
            }
        },
        modifier = Modifier.align(Alignment.Center),
        variant = ButtonVariant.Outline,
        tagKey = "show_boxed"
    )
    PantrixSnackbarHost(
        state = snackbars,
        style = SnackbarStyle(placement = placement, maxWidth = 320.dp),
        colors = SnackbarColors(container = own.color, content = own.onColor)
    )
}
val shown: SnackbarData? = snackbars.current
PantrixText(
    text = shown?.let { "In the box: ${it.message}" } ?: "No snackbar in the box",
    style = PantrixTheme.typography.footnote,
    color = PantrixTheme.colors.textSecondary
)
if (shown != null) {
    PantrixButton(
        text = "Remove",
        onClick = shown::dismiss,
        variant = ButtonVariant.Outline,
        size = ButtonSize.Small,
        tagKey = "dismiss_boxed"
    )
}

private val PLACEMENTS = listOf(
    SegmentOption(value = SnackbarPlacement.Top, label = "Top", tagKey = "top"),
    SegmentOption(value = SnackbarPlacement.BottomStart, label = "Bottom start", tagKey = "bottom_start"),
    SegmentOption(value = SnackbarPlacement.BottomCenter, label = "Bottom centre", tagKey = "bottom_center"),
    SegmentOption(value = SnackbarPlacement.BottomEnd, label = "Bottom end", tagKey = "bottom_end")
)

API

state

Puts the snackbars of state on screen: short messages about what the app has just done, each with at most one action, in a neutral, primary or semantic tone, one at a time.

ParameterTypeDefaultWhat it is
statePantrixSnackbarStatethe snackbars to show; PantrixSnackbarState.show asks for one.
modifierModifierModifierthe host's modifier. The host fills the space it is given.
styleSnackbarStyle?nullthis call's own style: over every enclosing one and under the density. Only the fields it sets change, on every platform.
colorsSnackbarColors?nullthe colours of every snackbar this host shows, where they are not their tones': the ones set here, with the tone's for the rest.

SnackbarStyle

PantrixSnackbarHost's placement and its snackbars' geometry.

ParameterTypeDefaultWhat it is
placementSnackbarPlacement?nullWhere the host shows its snackbars: in the middle of the bottom edge on Android, at the top on iOS, and in the bottom end corner on desktop and the web.
shapeShapeSpecShapeSpec.UnspecifiedThe snackbar's corners: extra small on Android, desktop and the web, large on iOS.
maxWidthDpDp.UnspecifiedThe widest a snackbar gets. In a narrower host it spans the host, less the margin.
minHeightDpDp.UnspecifiedThe height of a snackbar whose message fits on one line.
marginDpDp.UnspecifiedKept clear between a snackbar and the host's edges, inside the window's safe area.
elevationDpDp.UnspecifiedHow high the snackbar floats over the app, which is how large its shadow is.

SnackbarColors

What the snackbars of a PantrixSnackbarHost are painted with, for a host that does not take their tones' colours: a colour that is set is used on every snackbar of the host, and one that is left out stays the tone's.

ParameterTypeDefaultWhat it is
containerColorColor.UnspecifiedThe snackbar's fill. The color of its tone's role.
contentColorColor.UnspecifiedThe message, the icon, the action's label and the close button's cross: the colour that reads on container. The onColor of its tone's role.

Accessibility