处理用户互动

界面组件可通过特定的互动响应方式,向设备用户提供反馈。每个组件都有自己的互动响应方式,这有助于用户了解互动如何进行。例如,如果用户轻触设备触摸屏上的某个按钮,该按钮可能会以某种方式发生变化,比如添加突出显示颜色。此变化可让用户知道他们触摸了该按钮。如果用户不希望 他们就会知道要先将手指从按钮上移开 否则,该按钮将会激活。

图 1. 按钮始终处于启用状态,不会出现按压涟漪。
图 2. 按钮的按下涟漪可相应地反映其启用状态。

Compose 手势文档介绍了 Compose 组件如何处理低级别指针事件(例如指针移动和点击)。Compose 默认会将这些低级别事件抽象化处理为更高级别的互动,例如,一系列指针事件合起来可产生“按下和松开按钮”的效果。了解这类更高级别的抽象化处理,您就可以更好地自定义界面对用户的响应方式。例如,您可能想要自定义组件在与用户互动时外观如何变化,或者想要保留这些用户操作的日志。本文档为您提供了相关信息,以便您了解如何修改标准界面元素或设计自己的界面元素。

<ph type="x-smartling-placeholder">

互动

在许多情况下,您无需了解 Compose 组件如何解读用户互动。例如,Button 通过 Modifier.clickable 来判断用户是否点击了按钮。如果您要在应用中添加一个普通的按钮,可以定义该按钮的 onClick 代码,而 Modifier.clickable 将会适时运行该代码。这意味着,您不必知道用户是点按了屏幕还是通过键盘选择了该按钮;Modifier.clickable 会知道用户执行了点击操作,并通过运行 onClick 代码来做出响应。

不过,如果您想自定义界面组件对用户行为的响应方式,则可能需要详细了解背后的运作原理。本部分将对此进行说明。

当用户与界面组件互动时,系统会生成多个 Interaction 事件来表示其行为。例如,如果用户轻触某个按钮,该按钮会生成 PressInteraction.Press。如果用户在按钮范围内松开手指,系统就会生成 PressInteraction.Release,让按钮知道点击已完成。相反,如果用户将手指拖动到按钮范围外再松开,按钮就会生成 PressInteraction.Cancel,表示按下按钮的操作已取消,并未完成。

这些互动无预设立场。也就是说,这些低级别互动事件不会解读用户操作的含义或序列,也不会解读用户操作的优先顺序。

这些互动通常成对出现,包含起始互动和结尾互动。第二次互动包含对第一次互动的引用。例如,如果用户轻触某个按钮后松开手指,轻触操作会生成 PressInteraction.Press 互动,松开操作则会生成 PressInteraction.ReleaseRelease 具有 press 属性,用于识别初始 PressInteraction.Press

您可以观察特定组件的 InteractionSource 来查看其互动。InteractionSource 基于 Kotlin 构建 流,因此您可以通过相同的方式从中收集互动, 您可以采用任何其他流程如需详细了解此设计决策 请参阅 Illuminating Interactions 博文。

互动状态

您可以同时自行跟踪互动,以扩展组件的内置功能。例如,您可能希望某个按钮在被按下时改变颜色。最简单的互动跟踪方法就是观察相应的互动状态。InteractionSource 提供了多种方法来获取各种互动状态。例如,如果您想查看是否按下了特定按钮,可以调用其 InteractionSource.collectIsPressedAsState() 方法:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

collectIsPressedAsState() 之外,Compose 还提供了 collectIsFocusedAsState()collectIsDraggedAsState()collectIsHoveredAsState()。这些方法实际上是基于较低级别 InteractionSource API 的便捷方法。在某些情况下,建议您直接使用这些较低级别的函数。

例如,假设您需要知道用户是否按下并拖动了按钮。如果您同时使用 collectIsPressedAsState()collectIsDraggedAsState(),Compose 会执行大量重复工作,且无法保证互动顺序正确。对于这类情况,您可能需要直接使用 InteractionSource。如需详细了解如何跟踪 使用 InteractionSource,请参阅使用 InteractionSource

以下部分介绍了如何使用和发出 InteractionSourceMutableInteractionSource

使用和发出 Interaction

InteractionSource 表示 Interactions 的只读流,它不是 可以向 InteractionSource 发出 Interaction。发射 Interaction 时,您需要使用 MutableInteractionSource,它扩展自 InteractionSource

修饰符和组件可以消耗和发出 Interactions。 以下部分介绍了如何使用和发出二者的交互 修饰符和组件。

使用修饰符示例

对于为聚焦状态绘制边框的修饰符,您只需要观察 Interactions,因此您可以接受 InteractionSource

fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier {
    // ...
}

从函数签名可以明显看出,此修饰符是一个使用方, 可以消耗 Interaction,但不能发出它们。

生成修饰符示例

对于处理 Modifier.hoverable 等悬停事件的修饰符,您需要 需要发出 Interactions,并接受 MutableInteractionSource 作为 参数:

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

此修饰符是一个生产方,它可以使用提供的 MutableInteractionSource:在鼠标悬停时发出 HoverInteractions;或者 未被悬停。

构建消耗和生成内容的组件

Material Button 等高级组件既是提供方,又是提供方 。它们可以处理输入和聚焦事件,还可以更改其外观 以对事件作出响应,例如显示涟漪或 高度。因此,它们直接将 MutableInteractionSource 作为 参数,以便您可以提供自己记住的实例:

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,

    // exposes MutableInteractionSource as a parameter
    interactionSource: MutableInteractionSource? = null,

    elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) { /* content() */ }

这允许提升 MutableInteractionSource 从组件中移出,并观察所有 组件生成的 Interaction。您可以使用它来控制 该组件或界面中任何其他组件的外观。

如果您要自行构建互动式高级组件,我们建议您 以这种方式将 MutableInteractionSource 作为参数公开。除了 遵循状态提升最佳实践,这也使得阅读和 控制组件的视觉状态, 可以读取和控制状态(例如启用状态)。

Compose 采用分层架构方法, 因此高级 Material 组件构建在基础建筑之上, 这些程序块会生成控制涟漪和其他效果所需的 Interaction, 视觉效果。基础库提供高级交互修饰符 例如 Modifier.hoverableModifier.focusableModifier.draggable

要构建响应悬停事件的组件,您只需使用 Modifier.hoverable 并将 MutableInteractionSource 作为参数传递。 每当组件悬停时,它都会发出 HoverInteraction,您可以使用 更改该组件的显示方式

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

为使此组件也可聚焦,您可以添加 Modifier.focusable 并传递 与参数相同的 MutableInteractionSource。现在, 已发出 HoverInteraction.Enter/ExitFocusInteraction.Focus/Unfocus 即可通过同一 MutableInteractionSource 实现,并且您可以自定义 两种互动方式的呈现效果:

// This InteractionSource will emit hover and focus interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource)
        .focusable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Modifier.clickable 更高 与 hoverablefocusable 相比,是抽象层。 可点击,它隐式地可悬停,可以点击的组件应该 而且具有可聚焦性您可以使用 Modifier.clickable 创建一个组件, 处理悬停、聚焦和按下互动,而无需结合使用 级别 API。如果您还想将组件设为可点击 将 hoverablefocusable 替换为 clickable

// This InteractionSource will emit hover, focus, and press interactions
val interactionSource = remember { MutableInteractionSource() }
Box(
    Modifier
        .size(100.dp)
        .clickable(
            onClick = {},
            interactionSource = interactionSource,

            // Also show a ripple effect
            indication = ripple()
        ),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

使用 InteractionSource

如果您需要低级别的组件互动信息,可以为该组件的 InteractionSource 使用标准 Flow API。例如,假设您想要维护 InteractionSource 的按下和拖动互动列表。以下代码会执行一半的工作,在发生新的按下互动时立即将其添加到列表中:

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
        }
    }
}

但是,除了添加新的互动之外,您还必须在互动结束(例如,用户将手指从组件上松开)时移除互动。这很简单,因为结尾互动一律带有对关联的起始互动的引用。以下代码展示了如何移除已结束的互动:

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is PressInteraction.Release -> {
                interactions.remove(interaction.press)
            }
            is PressInteraction.Cancel -> {
                interactions.remove(interaction.press)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
            is DragInteraction.Stop -> {
                interactions.remove(interaction.start)
            }
            is DragInteraction.Cancel -> {
                interactions.remove(interaction.start)
            }
        }
    }
}

现在,如果您想知道该组件目前是否处于按下或拖动状态,只需检查 interactions 是否为空即可:

val isPressedOrDragged = interactions.isNotEmpty()

如果您想知道最近一次互动是什么 列表中。例如,以上就是使用 Compose 实现的涟漪效果 确定用于最近的互动的适当状态叠加层:

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

由于所有 Interaction 都遵循相同的结构,因此没有太多的 处理不同类型的用户互动(如 都是一样的

请注意,本部分前面的示例表示 Flow 使用 State 进行的互动 — 这样可以轻松观察更新的值, 因为读取状态值会自动导致重组。不过, 合成是在帧前进行批量处理的。这意味着,如果状态发生变化, 然后在同一帧内更改回状态,观察到该状态的组件 了解更改。

这对互动非常重要,因为互动有规律地开始和结束 在同一个框架中例如,将前面的示例与 Button 搭配使用:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

如果点按操作在同一帧内开始和结束,相应文本绝不会显示为 “按下!”。在大多数情况下,这并不是问题,因为系统将显示 这么短的时间就会导致闪烁 可被用户察觉到在某些情况下(例如显示涟漪效果或 您可能需要至少显示一定时长的 而不是在用户不再按下该按钮时立即停止播放。接收者 那么你可以直接在集合内启动和停止动画 lambda,而不是写入状态。在 构建带动画边框的高级 Indication 部分。

示例:使用自定义互动处理的构建组件

如需了解如何构建包含自定义输入响应的组件,请参考以下修改后的按钮示例。在此示例中,假设您想在用户按下按钮时使按钮外观发生变化:

动画:在用户点击时动态添加购物车图标的按钮
图 3. 一个按钮,点击后可动态添加图标。

为此,请基于 Button 构建自定义可组合项,并让其利用额外的 icon 参数来绘制图标(在本示例中为购物车图标)。您需要调用 collectIsPressedAsState() 来跟踪用户手指是否悬停在按钮上;如果悬停在按钮上,则添加图标。代码如下所示:

@Composable
fun PressIconButton(
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    text: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource? = null
) {
    val isPressed = interactionSource?.collectIsPressedAsState()?.value ?: false

    Button(
        onClick = onClick,
        modifier = modifier,
        interactionSource = interactionSource
    ) {
        AnimatedVisibility(visible = isPressed) {
            if (isPressed) {
                Row {
                    icon()
                    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
                }
            }
        }
        text()
    }
}

使用新的可组合项,如下所示:

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

由于这个新的 PressIconButton 以现有 Material Button 为基础,因此会照常响应用户互动。当用户 不透明度会略微改变,就像普通的 资料 Button

使用 Indication 创建和应用可重复使用的自定义效果

在前面的部分中,您学习了如何在响应中 不同的 Interaction 图标,例如在按下时显示图标。同样 方法可用于更改您提供给 组件,或更改组件内显示的内容,但这是 只适用于每个组件通常,应用或设计系统 将拥有一个通用系统来提供有状态的视觉效果,这种效果应该 以一致的方式应用于所有组件

如果你要构建这种设计体系,既要自定义一个组件, 将此自定义重复用于其他组件可能很困难, 原因如下:

  • 设计系统中的每个组件都需要相同的样板
  • 我们很容易忘记将此效果应用到新构建的组件和自定义 可点击组件
  • 可能很难将自定义效果与其他效果结合起来

为了避免这些问题并轻松地在整个系统中扩缩自定义组件, 您可以使用 IndicationIndication 表示可重复使用的视觉效果,可应用于 某些组件。Indication一分为二 部分:

  • IndicationNodeFactory:用于创建 Modifier.Node 实例的工厂, 为组件渲染视觉效果对于 它可以是单一实例(对象),并且可以在整个 整个应用

    这些实例可以是有状态实例,也可以是无状态实例。由于它们是 组件,它们可以从 CompositionLocal 检索值,以更改方式 与其他所有组件一样 Modifier.Node

  • Modifier.indication: 一个修饰符,用于绘制Indication 组件。Modifier.clickable 和其他高级互动修饰符 直接接受指示参数,这样它们不仅能发出 Interaction,但也可以为其 Interaction 绘制视觉效果 emit。因此,在简单的情况下,您可以只使用 Modifier.clickable,而不使用 需要 Modifier.indication

使用 Indication 替换效果

本部分介绍了如何替换应用于图片对象的手动缩放效果 特定按钮,带有可在多个位置重复使用的等效指示 组件。

以下代码将创建一个在按下时缩小的按钮:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f, label = "scale")

Button(
    modifier = Modifier.scale(scale),
    onClick = { },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

如需将上述代码段中的缩放效果转换为 Indication,请按照下列步骤操作: 具体步骤:

  1. 创建负责应用缩放效果的 Modifier.Node。 附加后,节点会观察互动来源,这与之前表示的类似 示例。唯一的区别在于,它会直接启动动画 而不是将传入的互动转换为状态。

    节点需要实现 DrawModifierNode,以便可以替换 ContentDrawScope#draw(),然后使用相同的绘图渲染缩放效果 命令与 Compose 中的任何其他图形 API 相同。

    调用 ContentDrawScope 接收器提供的 drawContent() 将绘制 实际应该应用 Indication 的组件,只需 需要在缩放转换中调用此函数。请确保您的 Indication 实现始终会在某个时间点调用 drawContent(); 否则,系统将不会绘制您应用 Indication 的组件。

    private class ScaleNode(private val interactionSource: InteractionSource) :
        Modifier.Node(), DrawModifierNode {
    
        var currentPressPosition: Offset = Offset.Zero
        val animatedScalePercent = Animatable(1f)
    
        private suspend fun animateToPressed(pressPosition: Offset) {
            currentPressPosition = pressPosition
            animatedScalePercent.animateTo(0.9f, spring())
        }
    
        private suspend fun animateToResting() {
            animatedScalePercent.animateTo(1f, spring())
        }
    
        override fun onAttach() {
            coroutineScope.launch {
                interactionSource.interactions.collectLatest { interaction ->
                    when (interaction) {
                        is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                        is PressInteraction.Release -> animateToResting()
                        is PressInteraction.Cancel -> animateToResting()
                    }
                }
            }
        }
    
        override fun ContentDrawScope.draw() {
            scale(
                scale = animatedScalePercent.value,
                pivot = currentPressPosition
            ) {
                this@draw.drawContent()
            }
        }
    }

  2. 创建 IndicationNodeFactory。它的唯一责任就是 新节点实例。由于没有 参数来配置指示,则工厂可以是对象:

    object ScaleIndication : IndicationNodeFactory {
        override fun create(interactionSource: InteractionSource): DelegatableNode {
            return ScaleNode(interactionSource)
        }
    
        override fun equals(other: Any?): Boolean = other === ScaleIndication
        override fun hashCode() = 100
    }

  3. Modifier.clickable 在内部使用 Modifier.indication,因此 带有 ScaleIndication 的可点击组件,您只需提供 Indication 作为 clickable 的参数

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

    这也使得使用自定义 Indication - 按钮可能如下所示:

    @Composable
    fun ScaleButton(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        interactionSource: MutableInteractionSource? = null,
        shape: Shape = CircleShape,
        content: @Composable RowScope.() -> Unit
    ) {
        Row(
            modifier = modifier
                .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp)
                .clickable(
                    enabled = enabled,
                    indication = ScaleIndication,
                    interactionSource = interactionSource,
                    onClick = onClick
                )
                .border(width = 2.dp, color = Color.Blue, shape = shape)
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
            content = content
        )
    }

然后,您可以通过以下方式使用该按钮:

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

动画:带有购物车图标的按钮,按下后会变小
图 4. 一个使用自定义 Indication 构建的按钮。

构建带有动画边框的高级 Indication

Indication 不仅仅局限于转换效果,例如缩放 组件。由于 IndicationNodeFactory 会返回 Modifier.Node,因此您可以绘制 就像使用其他绘图 API 一样,在内容上方或下方添加任何类型的效果。对于 例如,您可以在组件周围绘制动画边框,并在 顶部:

按下后有绚丽彩虹效果的按钮
图 5. 使用 Indication 绘制的动画边框效果。

此处的 Indication 实现与上一个示例非常相似: 它只是创建一个包含一些参数的节点。由于动画边框取决于 使用 Indication 的组件的形状和边框上, 实现 Indication 时还需要提供形状和边框宽度 作为参数:

data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory {

    override fun create(interactionSource: InteractionSource): DelegatableNode {
        return NeonNode(
            shape,
            // Double the border size for a stronger press effect
            borderWidth * 2,
            interactionSource
        )
    }
}

Modifier.Node 实现在概念上也相同,即使 绘制代码更加复杂。与之前一样,它会遵循 InteractionSource 连接后启动动画,并实现 DrawModifierNode 进行绘制 对内容的影响:

private class NeonNode(
    private val shape: Shape,
    private val borderWidth: Dp,
    private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
    var currentPressPosition: Offset = Offset.Zero
    val animatedProgress = Animatable(0f)
    val animatedPressAlpha = Animatable(1f)

    var pressedAnimation: Job? = null
    var restingAnimation: Job? = null

    private suspend fun animateToPressed(pressPosition: Offset) {
        // Finish any existing animations, in case of a new press while we are still showing
        // an animation for a previous one
        restingAnimation?.cancel()
        pressedAnimation?.cancel()
        pressedAnimation = coroutineScope.launch {
            currentPressPosition = pressPosition
            animatedPressAlpha.snapTo(1f)
            animatedProgress.snapTo(0f)
            animatedProgress.animateTo(1f, tween(450))
        }
    }

    private fun animateToResting() {
        restingAnimation = coroutineScope.launch {
            // Wait for the existing press animation to finish if it is still ongoing
            pressedAnimation?.join()
            animatedPressAlpha.animateTo(0f, tween(250))
            animatedProgress.snapTo(0f)
        }
    }

    override fun onAttach() {
        coroutineScope.launch {
            interactionSource.interactions.collect { interaction ->
                when (interaction) {
                    is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                    is PressInteraction.Release -> animateToResting()
                    is PressInteraction.Cancel -> animateToResting()
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition(
            currentPressPosition, size
        )
        val brush = animateBrush(
            startPosition = startPosition,
            endPosition = endPosition,
            progress = animatedProgress.value
        )
        val alpha = animatedPressAlpha.value

        drawContent()

        val outline = shape.createOutline(size, layoutDirection, this)
        // Draw overlay on top of content
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha * 0.1f
        )
        // Draw border on top of overlay
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha,
            style = Stroke(width = borderWidth.toPx())
        )
    }

    /**
     * Calculates a gradient start / end where start is the point on the bounding rectangle of
     * size [size] that intercepts with the line drawn from the center to [pressPosition],
     * and end is the intercept on the opposite end of that line.
     */
    private fun calculateGradientStartAndEndFromPressPosition(
        pressPosition: Offset,
        size: Size
    ): Pair<Offset, Offset> {
        // Convert to offset from the center
        val offset = pressPosition - size.center
        // y = mx + c, c is 0, so just test for x and y to see where the intercept is
        val gradient = offset.y / offset.x
        // We are starting from the center, so halve the width and height - convert the sign
        // to match the offset
        val width = (size.width / 2f) * sign(offset.x)
        val height = (size.height / 2f) * sign(offset.y)
        val x = height / gradient
        val y = gradient * width

        // Figure out which intercept lies within bounds
        val intercept = if (abs(y) <= abs(height)) {
            Offset(width, y)
        } else {
            Offset(x, height)
        }

        // Convert back to offsets from 0,0
        val start = intercept + size.center
        val end = Offset(size.width - start.x, size.height - start.y)
        return start to end
    }

    private fun animateBrush(
        startPosition: Offset,
        endPosition: Offset,
        progress: Float
    ): Brush {
        if (progress == 0f) return TransparentBrush

        // This is *expensive* - we are doing a lot of allocations on each animation frame. To
        // recreate a similar effect in a performant way, it would be better to create one large
        // gradient and translate it on each frame, instead of creating a whole new gradient
        // and shader. The current approach will be janky!
        val colorStops = buildList {
            when {
                progress < 1 / 6f -> {
                    val adjustedProgress = progress * 6f
                    add(0f to Blue)
                    add(adjustedProgress to Color.Transparent)
                }
                progress < 2 / 6f -> {
                    val adjustedProgress = (progress - 1 / 6f) * 6f
                    add(0f to Purple)
                    add(adjustedProgress * MaxBlueStop to Blue)
                    add(adjustedProgress to Blue)
                    add(1f to Color.Transparent)
                }
                progress < 3 / 6f -> {
                    val adjustedProgress = (progress - 2 / 6f) * 6f
                    add(0f to Pink)
                    add(adjustedProgress * MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 4 / 6f -> {
                    val adjustedProgress = (progress - 3 / 6f) * 6f
                    add(0f to Orange)
                    add(adjustedProgress * MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 5 / 6f -> {
                    val adjustedProgress = (progress - 4 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                else -> {
                    val adjustedProgress = (progress - 5 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxYellowStop to Yellow)
                    add(MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
            }
        }

        return linearGradient(
            colorStops = colorStops.toTypedArray(),
            start = startPosition,
            end = endPosition
        )
    }

    companion object {
        val TransparentBrush = SolidColor(Color.Transparent)
        val Blue = Color(0xFF30C0D8)
        val Purple = Color(0xFF7848A8)
        val Pink = Color(0xFFF03078)
        val Orange = Color(0xFFF07800)
        val Yellow = Color(0xFFF0D800)
        const val MaxYellowStop = 0.16f
        const val MaxOrangeStop = 0.33f
        const val MaxPinkStop = 0.5f
        const val MaxPurpleStop = 0.67f
        const val MaxBlueStop = 0.83f
    }
}

主要区别在于,现在 使用 animateToResting() 函数实现动画效果,这样即使按下按钮, 媒体播放时,按下动画会继续播放。还有处理 在animateToPressed开始时多次快速按压 - 如果按 发生在现有的按下或静止动画期间,上一个动画为 并且按下动画会从头开始播放。为了支持 并发效果(如使用涟漪效果,这时系统会绘制新的涟漪动画 则可以以列表形式跟踪动画,而不是 用于取消现有动画并开始播放新的动画。