处理用户交互

用户界面组件通过其对用户交互的响应方式向设备用户提供反馈。每个组件都有自己的交互响应方式,这有助于用户了解其交互的作用。例如,如果用户触摸设备触摸屏上的按钮,则按钮可能会以某种方式发生变化,例如添加高亮颜色。此更改让用户知道他们触摸了按钮。如果用户不想这样做,他们会知道在释放之前将手指从按钮上移开——否则,按钮将被激活。

图 1. 始终显示为启用的按钮,没有按下波纹。
图 2. 带有按下波纹的按钮,相应地反映其启用状态。

Compose 的 手势 文档介绍了 Compose 组件如何处理低级指针事件,例如指针移动和点击。默认情况下,Compose 将这些低级事件抽象为更高级别的交互——例如,一系列指针事件可能加起来构成一个按钮的按下和释放。了解这些更高级别的抽象可以帮助您自定义 UI 对用户的响应方式。例如,您可能希望自定义组件在用户与其交互时外观的变化方式,或者您可能只想维护这些用户操作的日志。本文档提供了修改标准 UI 元素或设计您自己的 UI 元素所需的信息。

交互

在许多情况下,您不需要知道 Compose 组件如何解释用户交互。例如,Button 依靠 Modifier.clickable 来确定用户是否点击了按钮。如果您在应用中添加了一个典型的按钮,您可以定义按钮的 onClick 代码,并且 Modifier.clickable 会在适当的时候运行该代码。这意味着您不需要知道用户是否轻触了屏幕或使用键盘选择了按钮;Modifier.clickable 会确定用户执行了点击操作,并通过运行您的 onClick 代码做出响应。

但是,如果您想自定义 UI 组件对用户行为的响应,您可能需要了解幕后发生的事情。本节将为您提供一些相关信息。

当用户与 UI 组件交互时,系统会通过生成多个 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 的只读流——无法将 Interaction 发射到 InteractionSource。要发射 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。您可以使用它来控制该组件或 UI 中任何其他组件的外观。

如果您正在构建自己的交互式高级组件,我们建议您以这种方式公开 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。如果要使您的组件也可点击,则可以使用clickable替换hoverablefocusable

// 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

如果您需要有关组件交互的低级信息,则可以使用标准的flow API来获取该组件的InteractionSource。例如,假设您想要维护一个组件的按下和拖动交互列表。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")
}

如果按下操作在同一帧内开始和结束,则文本将永远不会显示为“已按下!”。在大多数情况下,这不是问题——显示这么短时间的视觉效果会导致闪烁,用户不会注意到。在某些情况下,例如显示波纹效果或类似动画,您可能希望至少显示最短时间的效果,而不是在按钮不再被按下时立即停止。为此,您可以直接从 collect lambda 内部启动和停止动画,而不是写入状态。在构建具有动画边框的高级Indication部分中有一个此模式的示例。

示例:构建具有自定义交互处理的组件

要了解如何构建对输入具有自定义响应的组件,以下是一个修改后的按钮示例。在这种情况下,假设您希望一个按钮通过更改其外观来响应按下操作。

Animation of a button that dynamically adds a grocery cart icon when clicked
图 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:一个修饰符,用于为组件绘制IndicationModifier.clickable和其他高级交互修饰符直接接受 indication 参数,因此它们不仅发出Interaction,还可以为它们发出的Interaction绘制视觉效果。因此,对于简单的情况,您可以只使用Modifier.clickable,而无需使用Modifier.indication

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。附加后,节点会观察交互源,类似于前面的示例。这里唯一的区别是它直接启动动画,而不是将传入的 Interactions 转换为状态。

    节点需要实现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
            ) {
                [email protected]()
            }
        }
    }

  2. 创建IndicationNodeFactory。它的唯一职责是为提供的交互源创建一个新的节点实例。由于没有参数可以配置 indication,因此工厂可以是对象。

    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!")
}

An animation of a button with a grocery cart icon that becomes smaller when pressed
图 4. 使用自定义Indication构建的按钮。

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

Indication不仅限于变换效果,例如缩放组件。因为IndicationNodeFactory返回一个Modifier.Node,所以您可以像使用其他绘图 API 一样在内容上方或下方绘制任何类型的效果。例如,您可以在组件周围绘制一个动画边框,并在按下时在组件顶部绘制一个覆盖层。

A button with a fancy rainbow effect on press
图 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 的开头也处理了多次快速按下——如果在现有按下或恢复动画期间发生按下,则取消之前的动画,并从头开始按下动画。为了支持多个并发效果(例如波纹,其中新的波纹动画将在其他波纹之上绘制),您可以将动画跟踪在一个列表中,而不是取消现有动画并启动新的动画。