处理用户互动

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

图 1. 始终显示为启用状态的按钮,按下时不会出现涟漪。
图 2. 带有按下涟漪效果的按钮,可相应地反映其启用状态。

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

互动

在许多情况下,您无需了解 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 Flow 构建,因此您可以像使用任何其他数据流时一样从该数据流中收集互动。如需详细了解此设计决策,请参阅照亮互动博文。

互动状态

您可以同时自行跟踪互动,以扩展组件的内置功能。例如,您可能希望某个按钮在被按下时改变颜色。最简单的互动跟踪方法就是观察相应的互动状态。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,您需要使用从 InteractionSource 扩展的 MutableInteractionSource

修饰符和组件可以使用、发出或使用和发出 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,您可以使用此 API 来更改组件的显示方式。

// 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 都遵循相同的结构,因此在处理不同类型的用户互动时,代码没有太大区别 - 整体模式是相同的。

请注意,本部分前面的示例使用 State 表示互动的 Flow,这样有助于观察更新后的值,因为读取状态值会自动导致重组。但是,合成是在帧前批量进行批处理的。这意味着,如果状态发生变化,然后在同一帧中又发生变化,则观察该状态的组件不会看到这种变化。

这对互动非常重要,因为互动可以有规律地在同一帧中开始和结束。例如,使用上一个包含 Button 的示例:

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

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

如果按下按钮在同一帧中开始和结束,文本绝不会显示为“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 为基础,因此会照常响应用户互动。当用户按下该按钮时,按钮的不透明度会略有变化,就像普通的 Material Button 一样。

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

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

如果您正在构建此类设计系统,自定义一个组件以及对其他组件重复使用这种自定义设置可能会很困难,原因如下:

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

为避免这些问题并轻松地在整个系统中扩展自定义组件,您可以使用 IndicationIndication 表示可重复使用的视觉效果,它们可以应用于应用或设计系统中的各个组件。Indication 分为两部分:

  • IndicationNodeFactory:一个工厂,用于创建为组件渲染视觉效果的 Modifier.Node 实例。对于不会在组件之间变化的较简单实现,其可以是单例(对象),并在整个应用中重复使用。

    这些实例可以是有状态实例,也可以是无状态实例。由于它们是按组件创建的,因此它们可以从 CompositionLocal 检索值,以更改它们在特定组件内的显示方式或行为,就像对任何其他 Modifier.Node 一样。

  • Modifier.indication:用于为组件绘制 Indication 的修饰符。Modifier.clickable 和其他高级互动修饰符会直接接受指示参数,因此它们不仅可以发出 Interaction,还可以为其发出的 Interaction 绘制视觉效果。因此,对于简单情况,您只需使用 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 开始时处理多次快速按下动作。如果在现有按下或静止动画期间发生按下动作,系统会取消上一个动画,并从头开始按压动画。如需支持多种并发效果(例如,对于新的涟漪效果,即新的涟漪动画将在其他涟漪上绘制),您可以跟踪列表中的动画,而不是取消现有动画并开始新动画。