feat(components): spring the snack chip open

A slide-in is clipped to the animating bounds, so at pill size the chip
looked painted on from one edge. It now springs from its anchored corner on
the expressive fast specs, which are also quicker than the default
visibility spring.
This commit is contained in:
2026-08-02 18:27:32 +02:00
parent b614e427df
commit b08fb69843
@@ -5,9 +5,8 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.scaleOut
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -15,6 +14,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor import androidx.compose.material3.contentColorFor
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -31,17 +32,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
private const val CHIP_ENTER_MILLIS = 160
private const val CHIP_EXIT_MILLIS = 120
private const val CHIP_SWAP_MILLIS = 110
/** The band a snack chip is meant to sit in: the FAB's height, and its margin. */ /** The band a snack chip is meant to sit in: the FAB's height, and its margin. */
val SnackChipHeight: Dp = 56.dp val SnackChipHeight: Dp = 56.dp
val SnackChipMargin: Dp = 16.dp val SnackChipMargin: Dp = 16.dp
/** /**
* The family's compact confirmation: a rounded pill sized to its content, * The family's compact confirmation: a rounded pill sized to its content,
* sliding up from the bottom, with an optional trailing action ("Undo"). * springing open at the bottom, with an optional trailing action ("Undo").
* *
* Preferred over a full-width snackbar wherever the surface behind it *is* the * Preferred over a full-width snackbar wherever the surface behind it *is* the
* confirmation — a task list that just lost a row, a calendar that just moved an * confirmation — a task list that just lost a row, a calendar that just moved an
@@ -57,6 +54,7 @@ val SnackChipMargin: Dp = 16.dp
* The chip carries no timer: how long a confirmation stands, and what expiry * The chip carries no timer: how long a confirmation stands, and what expiry
* commits, belongs to the caller's own undo window. * commits, belongs to the caller's own undo window.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun SnackChip( fun SnackChip(
visible: Boolean, visible: Boolean,
@@ -68,14 +66,19 @@ fun SnackChip(
color: Color = MaterialTheme.colorScheme.surfaceContainerHighest, color: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor: Color = contentColorFor(color), contentColor: Color = contentColorFor(color),
) { ) {
// The expressive *fast* springs: a chip is a receipt, not a transition. It
// springs open from its anchored corner rather than sliding in — a slide is
// clipped to the animating bounds, so the pill appears to be painted on from
// one edge, which at this size reads as a glitch rather than as motion.
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
val corner = TransformOrigin(0f, 1f)
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
// Deliberately short: a chip is a receipt, not a transition. The default enter = scaleIn(spatial, initialScale = 0.8f, transformOrigin = corner) +
// visibility spring overshoots and reads as sluggish at this size. fadeIn(effects),
enter = slideInVertically(tween(CHIP_ENTER_MILLIS)) { it } + exit = scaleOut(effects, targetScale = 0.9f, transformOrigin = corner) +
fadeIn(tween(CHIP_ENTER_MILLIS)), fadeOut(effects),
exit = slideOutVertically(tween(CHIP_EXIT_MILLIS)) { it } +
fadeOut(tween(CHIP_EXIT_MILLIS)),
modifier = modifier, modifier = modifier,
) { ) {
val action = if (actionLabel != null && onAction != null) actionLabel else null val action = if (actionLabel != null && onAction != null) actionLabel else null
@@ -89,7 +92,7 @@ fun SnackChip(
// snapping, so the swap reads as one chip changing its mind. // snapping, so the swap reads as one chip changing its mind.
modifier = Modifier modifier = Modifier
.widthIn(max = maxWidth) .widthIn(max = maxWidth)
.animateContentSize(tween(CHIP_SWAP_MILLIS)), .animateContentSize(MaterialTheme.motionScheme.fastSpatialSpec()),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -103,10 +106,7 @@ fun SnackChip(
) { ) {
AnimatedContent( AnimatedContent(
targetState = message, targetState = message,
transitionSpec = { transitionSpec = { fadeIn(effects) togetherWith fadeOut(effects) },
fadeIn(tween(CHIP_SWAP_MILLIS)) togetherWith
fadeOut(tween(CHIP_SWAP_MILLIS))
},
label = "snack-chip-message", label = "snack-chip-message",
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
) { text -> ) { text ->