Como processar interações do usuário

Os componentes da interface do usuário fornecem feedback ao usuário do dispositivo pela maneira como eles respondem às interações do usuário. Cada componente tem a própria maneira de responder a interações, o que ajuda o usuário a saber o que as interações dele estão fazendo. Por exemplo, se um usuário tocar em um botão na tela touchscreen de um dispositivo, ele provavelmente mudará de alguma forma, talvez adicionando uma cor de destaque. Essa mudança informa ao usuário que ele tocou no botão. Se o usuário não quiser fazer isso, vai saber que precisa arrastar o dedo para fora do botão antes de soltar. Caso contrário, o botão será ativado.

Figura 1. Botões que sempre aparecem ativados, sem efeito de ondulação ao pressionar.
Figura 2. Botões com ondulações de pressão que refletem o estado ativado.

A documentação de Gestos do Compose aborda como os componentes do Compose processam eventos de ponteiro de baixo nível, como movimentos e cliques do ponteiro. O Compose abstrai esses eventos de baixo nível em interações de nível mais alto. Por exemplo, uma série de eventos de ponteiro pode ser adicionada a um botão de tocar e soltar. Entender essas abstrações de nível superior pode ajudar a personalizar a resposta da IU ao usuário. Por exemplo, você pode personalizar a aparência de um componente quando o usuário interage com ele ou apenas manter um registro dessas ações. Este documento fornece as informações necessárias para modificar os elementos de IU padrão ou criar seu próprio elemento.

Interações

Em muitos casos, você não precisa saber apenas como o componente do Compose está interpretando as interações do usuário. Por exemplo, Button depende de Modifier.clickable para descobrir se o usuário clicou no botão. Se estiver adicionando um botão típico ao seu app, você poderá definir o código onClick do botão, e o Modifier.clickable executará esse código quando adequado. Isso significa que você não precisa saber se o usuário tocou na tela ou selecionou o botão com um teclado. O Modifier.clickable descobre que o usuário executou um clique e responde executando o código onClick.

No entanto, se você quiser personalizar a resposta do componente de IU para o comportamento do usuário, talvez precise saber mais do que está acontecendo nos bastidores. Esta seção fornece algumas dessas informações.

Quando um usuário interage com um componente de IU, o sistema representa o comportamento gerando vários eventos de Interaction. Por exemplo, se um usuário tocar em um botão, ele vai gerar PressInteraction.Press. Se o usuário levantar o dedo dentro do botão, ele vai gerar um PressInteraction.Release, informando ao botão que o clique foi concluído. Por outro lado, se o usuário arrastar o dedo para fora do botão e levantar o dedo, o botão vai gerar PressInteraction.Cancel, para indicar que o pressionamento do botão foi cancelado, não concluído.

Essas interações são discretas. Ou seja, esses eventos de interação de baixo nível não pretendem interpretar o significado das ações do usuário ou a sequência delas. Eles também não interpretam quais ações do usuário podem ter prioridade sobre outras ações.

Essas interações geralmente vêm em pares, com um início e um fim. A segunda interação contém uma referência à primeira. Por exemplo, se um usuário tocar em um botão e levantar o dedo, o toque vai gerar uma interação PressInteraction.Press, e a liberação vai gerar um PressInteraction.Release. A Release tem uma propriedade press que identifica a PressInteraction.Press inicial.

Você pode ver as interações de um componente específico observando a InteractionSource. A InteractionSource é criada com base nos fluxos Kotlin para que você possa coletar as interações da mesma forma que trabalha com qualquer outro fluxo. Para mais informações sobre essa decisão de design, consulte a postagem do blog Iluminando interações.

Estado de interação

Para estender a funcionalidade integrada dos componentes, você também precisa monitorar as interações por conta própria. Por exemplo, talvez você queira que um botão mude de cor quando for pressionado. A maneira mais simples de acompanhar as interações é observar o estado adequado da interação. InteractionSource oferece alguns métodos que revelam vários status de interação como estado. Por exemplo, se você quiser ver se um botão específico foi pressionado, chame o método InteractionSource.collectIsPressedAsState() correspondente:

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

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

Além de collectIsPressedAsState(), o Compose também fornece collectIsFocusedAsState(), collectIsDraggedAsState() e collectIsHoveredAsState(). Na verdade, esses são métodos de conveniência baseados nas APIs InteractionSource de nível inferior. Em alguns casos, convém usar essas funções de nível inferior diretamente.

Por exemplo, suponha que você precise saber se um botão está sendo pressionado e também se ele está sendo arrastado. Se você usar collectIsPressedAsState() e collectIsDraggedAsState(), o Compose fará muito trabalho duplicado, e não há garantia de que você receberá todas as interações na ordem certa. Em situações como essa, você pode trabalhar diretamente com a InteractionSource. Para mais informações sobre como rastrear as interações por conta própria com InteractionSource, consulte Trabalhar com InteractionSource.

A seção a seguir descreve como consumir e emitir interações com InteractionSource e MutableInteractionSource, respectivamente.

Consumir e emitir Interaction

InteractionSource representa um stream somente leitura de Interactions. Não é possível emitir um Interaction para um InteractionSource. Para emitir Interactions, você precisa usar um MutableInteractionSource, que se estende de InteractionSource.

Modificadores e componentes podem consumir, emitir ou consumir e emitir Interactions. As seções a seguir descrevem como consumir e emitir interações de modificadores e componentes.

Exemplo de modificador de consumo

Para um modificador que desenha uma borda para o estado de foco, basta observar Interactions. Assim, você pode aceitar um InteractionSource:

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

Pela assinatura da função, fica claro que esse modificador é um consumidor: ele pode consumir Interactions, mas não pode emitir.

Exemplo de modificador de produção

Para um modificador que processa eventos de passar o cursor, como Modifier.hoverable, você precisa emitir Interactions e aceitar um MutableInteractionSource como parâmetro:

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

Esse modificador é um produtor. Ele pode usar o MutableInteractionSource fornecido para emitir HoverInteractions quando o cursor passa ou sai de cima dele.

Criar componentes que consomem e produzem

Componentes de alto nível, como um Button do Material, atuam como produtores e consumidores. Eles processam eventos de entrada e foco, além de mudar a aparência em resposta a esses eventos, como mostrar um efeito de ondulação ou animar a elevação. Como resultado, eles expõem diretamente MutableInteractionSource como um parâmetro, para que você possa fornecer sua própria instância lembrada:

@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() */ }

Isso permite o hoisting do MutableInteractionSource para fora do componente e a observação de todos os Interactions produzidos por ele. Você pode usar isso para controlar a aparência desse ou de qualquer outro componente na sua interface.

Se você estiver criando seus próprios componentes interativos de alto nível, recomendamos expor MutableInteractionSource como um parâmetro desta forma. Além de seguir as práticas recomendadas de elevação de estado, isso também facilita a leitura e o controle do estado visual de um componente da mesma forma que qualquer outro tipo de estado (como o estado ativado) pode ser lido e controlado.

O Compose segue uma abordagem arquitetônica em camadas. Assim, os componentes de alto nível do Material são criados com base em blocos fundamentais que produzem os Interactions necessários para controlar ondulações e outros efeitos visuais. A biblioteca de base fornece modificadores de interação de alto nível, como Modifier.hoverable, Modifier.focusable e Modifier.draggable.

Para criar um componente que responda a eventos de passar o cursor, basta usar Modifier.hoverable e transmitir um MutableInteractionSource como parâmetro. Sempre que o componente é passado com o cursor, ele emite HoverInteractions, e você pode usar isso para mudar a aparência dele.

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

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

Para também tornar esse componente focalizável, adicione Modifier.focusable e transmita o mesmo MutableInteractionSource como um parâmetro. Agora, HoverInteraction.Enter/Exit e FocusInteraction.Focus/Unfocus são emitidos pelo mesmo MutableInteractionSource, e você pode personalizar a aparência dos dois tipos de interação no mesmo lugar:

// 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 é uma abstração de nível ainda mais alto do que hoverable e focusable. Para que um componente seja clicável, ele é implicitamente passível de passar o cursor. Além disso, os componentes clicáveis também precisam ser focalizáveis. Você pode usar Modifier.clickable para criar um componente que processa interações de passar o cursor, foco e pressionar, sem precisar combinar APIs de nível inferior. Se você também quiser tornar o componente clicável, substitua hoverable e focusable por um 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!")
}

Trabalhe com o InteractionSource

Se você precisar de informações de baixo nível sobre interações com um componente, use as APIs de fluxo padrão para a InteractionSource desse componente. Por exemplo, suponha que você queira manter uma lista das interações de pressionar e arrastar para uma InteractionSource. Esse código faz metade do trabalho, adicionando os novos pressionamentos à lista conforme eles chegam:

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

Mas, além de adicionar as novas interações, você também precisará removê-las quando elas terminarem, por exemplo, quando o usuário levantar o dedo do componente. Isso é fácil, porque as interações finais sempre carregam uma referência à interação inicial associada. O código a seguir mostra como remover as interações que terminaram:

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

Agora, se você quiser saber se o componente está sendo pressionado ou arrastado, basta verificar se interactions está vazio:

val isPressedOrDragged = interactions.isNotEmpty()

Para saber qual foi a interação mais recente, basta analisar o último item da lista. Por exemplo, veja como a implementação de ondulação do Compose determina a sobreposição de estado adequada para usar na interação mais recente:

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

Como todos os Interactions seguem a mesma estrutura, não há muita diferença no código ao trabalhar com diferentes tipos de interações do usuário. O padrão geral é o mesmo.

Os exemplos anteriores nesta seção representam o Flow de interações usando State . Isso facilita a observação de valores atualizados, já que a leitura do valor do estado causa recomposições automaticamente. No entanto, a composição é em lote antes do frame. Isso significa que, se o estado mudar e mudar de volta no mesmo frame, os componentes que observam o estado não vão ver a mudança.

Isso é importante para as interações, já que elas podem começar e terminar regularmente no mesmo frame. Por exemplo, usando o exemplo anterior com Button:

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

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

Se uma ação de pressionar começar e terminar no mesmo frame, o texto nunca vai aparecer como "Pressed!". Na maioria dos casos, isso não é um problema. Mostrar um efeito visual por um período tão curto resulta em oscilação e não é muito perceptível para o usuário. Em alguns casos, como mostrar um efeito de ondulação ou uma animação semelhante, talvez seja melhor mostrar o efeito por um período mínimo em vez de interromper imediatamente se o botão não for mais pressionado. Para fazer isso, inicie e pare as animações diretamente de dentro da lambda de coleta, em vez de gravar em um estado. Há um exemplo desse padrão na seção Criar um Indication avançado com borda animada.

Exemplo: criar um componente com processamento de interação personalizado

Para ver como criar componentes com uma resposta personalizada para entrada, veja um exemplo de botão modificado. Nesse caso, suponha que você queira um botão que responda aos pressionamentos mudando a aparência:

Animação de um botão que adiciona dinamicamente um ícone de carrinho de compras quando clicado
Figura 3. Um botão que adiciona dinamicamente um ícone quando clicado.

Para fazer isso, crie um elemento personalizado que pode ser composto com base em Button e use um parâmetro icon adicional para desenhar o ícone, neste caso, um carrinho de compras. Você chama collectIsPressedAsState() para rastrear se o usuário está passando o cursor sobre o botão. Quando estiver, você vai adicionar o ícone. O código vai ficar assim:

@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()
    }
}

Veja como usar esse novo elemento de composição:

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

Como esse novo PressIconButton é criado com base no Button do Material já existente, ele reage às interações do usuário de todas as maneiras normais. Quando o usuário pressiona o botão, ele muda um pouco a opacidade, como um Button comum do Material.

Criar e aplicar um efeito personalizado reutilizável com Indication

Nas seções anteriores, você aprendeu a mudar parte de um componente em resposta a diferentes Interactions, como mostrar um ícone quando pressionado. Essa mesma abordagem pode ser usada para mudar o valor dos parâmetros fornecidos a um componente ou mudar o conteúdo exibido dentro de um componente, mas isso é aplicável apenas por componente. Muitas vezes, um aplicativo ou sistema de design tem um sistema genérico para efeitos visuais com estado, um efeito que deve ser aplicado a todos os componentes de maneira consistente.

Se você estiver criando esse tipo de sistema de design, personalizar um componente e reutilizar essa personalização para outros componentes pode ser difícil pelos seguintes motivos:

  • Cada componente no sistema de design precisa do mesmo boilerplate
  • É fácil esquecer de aplicar esse efeito a componentes recém-criados e componentes personalizados clicáveis.
  • Pode ser difícil combinar o efeito personalizado com outros efeitos

Para evitar esses problemas e dimensionar facilmente um componente personalizado em todo o sistema, use Indication. Indication representa um efeito visual reutilizável que pode ser aplicado a componentes em um aplicativo ou sistema de design. O Indication é dividido em duas partes:

  • IndicationNodeFactory: uma fábrica que cria instâncias Modifier.Node que renderizam efeitos visuais para um componente. Para implementações mais simples que não mudam entre componentes, isso pode ser um singleton (objeto) e reutilizado em todo o aplicativo.

    Essas instâncias podem ter ou não estado. Como são criados por componente, eles podem recuperar valores de um CompositionLocal para mudar a aparência ou o comportamento dentro de um componente específico, assim como qualquer outro Modifier.Node.

  • Modifier.indication: Um modificador que desenha Indication para um componente. Modifier.clickable e outros modificadores de interação de alto nível aceitam um parâmetro de indicação diretamente. Assim, eles não apenas emitem Interactions, mas também podem criar efeitos visuais para os Interactions que emitem. Portanto, em casos simples, basta usar Modifier.clickable sem precisar de Modifier.indication.

Substituir o efeito por um Indication

Nesta seção, descrevemos como substituir um efeito de escalonamento manual aplicado a um botão específico por uma indicação equivalente que pode ser reutilizada em vários componentes.

O código a seguir cria um botão que diminui ao ser pressionado:

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

Para converter o efeito de escalonamento no snippet acima em um Indication, siga estas etapas:

  1. Crie o Modifier.Node responsável por aplicar o efeito de escala. Quando anexado, o nó observa a origem da interação, semelhante aos exemplos anteriores. A única diferença é que ele inicia animações diretamente em vez de converter as interações recebidas em estado.

    O nó precisa implementar DrawModifierNode para substituir ContentDrawScope#draw() e renderizar um efeito de escala usando os mesmos comandos de desenho de qualquer outra API de gráficos no Compose.

    Chamar drawContent() disponível no receptor ContentDrawScope vai desenhar o componente real a que o Indication deve ser aplicado. Basta chamar essa função em uma transformação de escala. Verifique se as implementações de Indication sempre chamam drawContent() em algum momento. Caso contrário, o componente a que você está aplicando o Indication não será desenhado.

    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. Crie o IndicationNodeFactory. A única responsabilidade dele é criar uma nova instância de nó para uma origem de interação fornecida. Como não há parâmetros para configurar a indicação, a fábrica pode ser um objeto:

    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. O Modifier.clickable usa o Modifier.indication internamente. Portanto, para criar um componente clicável com ScaleIndication, basta fornecer o Indication como um parâmetro para clickable:

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

    Isso também facilita a criação de componentes reutilizáveis de alto nível usando um Indication personalizado. Um botão pode ter esta aparência:

    @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
        )
    }

Em seguida, use o botão da seguinte maneira:

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

Animação de um botão com um ícone de carrinho de compras que diminui quando pressionado
Figura 4. Um botão criado com um Indication
personalizado.

Criar um Indication avançado com borda animada

O Indication não se limita apenas a efeitos de transformação, como dimensionar um componente. Como IndicationNodeFactory retorna um Modifier.Node, é possível desenhar qualquer tipo de efeito acima ou abaixo do conteúdo, assim como com outras APIs de desenho. Por exemplo, você pode desenhar uma borda animada ao redor do componente e uma sobreposição em cima dele quando ele for pressionado:

Um botão com um efeito de arco-íris sofisticado ao ser pressionado
Figura 5. Um efeito de borda animado desenhado com Indication.

A implementação de Indication aqui é muito semelhante ao exemplo anterior. Ela apenas cria um nó com alguns parâmetros. Como a borda animada depende da forma e da borda do componente em que o Indication é usado, a implementação do Indication também exige que a forma e a largura da borda sejam fornecidas como parâmetros:

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

A implementação de Modifier.Node também é conceitualmente a mesma, mesmo que o código de desenho seja mais complicado. Como antes, ele observa InteractionSource quando anexado, inicia animações e implementa DrawModifierNode para desenhar o efeito sobre o conteúdo:

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

A principal diferença é que agora há uma duração mínima para a animação com a função animateToResting(). Assim, mesmo que a ação de pressionar seja imediatamente liberada, a animação vai continuar. Também há processamento para vários toques rápidos no início de animateToPressed. Se um toque acontecer durante um toque ou uma animação de descanso, a animação anterior será cancelada, e a animação de toque vai começar do início. Para oferecer suporte a vários efeitos simultâneos (como ondulações, em que uma nova animação de ondulação é desenhada sobre outras ondulações), é possível rastrear as animações em uma lista, em vez de cancelar as animações atuais e iniciar novas.