Обработка действий пользователя

Компоненты пользовательского интерфейса дают обратную связь пользователю устройства, реагируя на его действия. Каждый компонент по-своему реагирует на взаимодействия, что помогает пользователю понять, что делают его взаимодействия. Например, если пользователь нажимает кнопку на сенсорном экране устройства, кнопка может изменить цвет. Это изменение дает пользователю знать, что он нажал кнопку. Если пользователь не хочет этого делать, он должен убрать палец с кнопки, прежде чем отпустить ее, иначе кнопка сработает.

Рисунок 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.Release. У Release есть свойство 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(). Эти методы являются вспомогательными и основаны на API InteractionSource более низкого уровня. В некоторых случаях вы можете захотеть использовать эти функции более низкого уровня напрямую.

Например, вам нужно знать, нажимается ли кнопка и также перетаскивается ли она. Если вы используете и collectIsPressedAsState(), и collectIsDraggedAsState(), Compose выполняет много повторяющейся работы, и нет гарантии, что вы получите все взаимодействия в правильном порядке. В таких ситуациях вы можете обратиться напрямую в InteractionSource. Подробнее о том InteractionSource, как работать с InteractionSource…

В следующем разделе описывается, как использовать и генерировать взаимодействия с помощью InteractionSource и MutableInteractionSource соответственно.

Поглощение и выделение Interaction

InteractionSource представляет собой поток Interactions, доступный только для чтения. Невозможно передать Interaction в InteractionSource. Чтобы передавать Interaction, нужно использовать MutableInteractionSource, который расширяет InteractionSource.

Модификаторы и компоненты могут потреблять, излучать или потреблять и излучать Interactions. В следующих разделах описывается, как использовать и генерировать взаимодействия как из модификаторов, так и из компонентов.

Пример модификатора, который расходуется

Для модификатора, который рисует границу для состояния фокуса, вам нужно только наблюдать за Interactions, поэтому вы можете принять InteractionSource:

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

Из сигнатуры функции видно, что этот модификатор является потребителем – он может принимать Interaction, но не может их выдавать.

Пример модификатора Producing

Для модификатора, который обрабатывает события наведения, например 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, необходимые для управления рябью и другими визуальными эффектами. Библиотека Foundation предоставляет модификаторы взаимодействия высокого уровня, такие как Modifier.hoverable, Modifier.focusable и Modifier.draggable.

Чтобы создать компонент, реагирующий на события наведения, вы можете просто использовать Modifier.hoverable и передать MutableInteractionSource в качестве параметра. При наведении указателя мыши на компонент он генерирует HoverInteractions, и вы можете использовать это, чтобы изменить внешний вид компонента.

// 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/Exit, и FocusInteraction.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 – это ещё более высокий уровень абстракции, чем hoverable и focusable. Чтобы компонент был кликабельным, он должен неявно поддерживать наведение курсора, а компоненты, на которые можно нажать, также должны поддерживать фокусировку. Вы можете использовать Modifier.clickable для создания компонента, который обрабатывает взаимодействия при наведении, фокусе и нажатии, без необходимости комбинировать API более низкого уровня. Если вы хотите, чтобы на компонент можно было нажать, замените hoverable и focusable на 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

Если вам нужна низкоуровневая информация о взаимодействиях с компонентом, вы можете использовать стандартные 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 ripple определяет подходящий оверлей состояния, который будет использоваться для самого последнего взаимодействия:

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

Если нажатие начинается и заканчивается в одном кадре, текст "Нажато!" не появится. В большинстве случаев это не проблема – показ визуального эффекта в течение такого короткого промежутка времени приведет к мерцанию и не будет очень заметен пользователю. В некоторых случаях, например при показе эффекта пульсации или аналогичной анимации, вы можете захотеть показывать эффект в течение как минимум минимального количества времени, а не немедленно останавливаться, если кнопка больше не нажата. Для этого вы можете напрямую запускать и останавливать анимацию из лямбда-выражения collect, а не записывать ее в состояние. Пример такого шаблона приведен в разделе Создание расширенного элемента Indication с анимированной рамкой.

Пример: создание компонента с настраиваемой обработкой взаимодействия

Чтобы увидеть, как можно создавать компоненты с настраиваемым ответом на ввод, вот пример модифицированной кнопки. В этом случае предположим, что вам нужна кнопка, которая реагирует на нажатия, меняя свой внешний вид:

Анимированное изображение кнопки, на которой при нажатии появляется значок корзины для покупок
Рисунок 3. Кнопка, при нажатии на которую динамически добавляется значок.

Для этого создайте настраиваемую composable-функцию на основе 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()
    }
}

Вот как выглядит использование этой новой composable-функции:

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

Поскольку новый PressIconButton построен на основе существующего Material Button, он реагирует на взаимодействия с пользователем всеми обычными способами. Когда пользователь нажимает на кнопку, ее прозрачность немного меняется, как и у обычного элемента Material Button.

Как создать и применить многоразовый настраиваемый эффект с помощью Indication

В предыдущих разделах вы узнали, как изменить часть компонента в ответ на различные Interaction, например, показать значок при нажатии. Этот же подход можно использовать для изменения значения параметров, которые вы передаете компоненту, или для изменения контента, отображаемого внутри компонента, но это применимо только для каждого компонента в отдельности. Часто приложение или система проектирования имеют общую систему визуальных эффектов с отслеживанием состояния — эффект, который должен применяться ко всем компонентам единообразно.

Если вы создаете такую систему проектирования, настроить один компонент и повторно использовать эту настройку для других компонентов может быть сложно по следующим причинам:

  • Каждому компоненту в системе проектирования нужен один и тот же шаблон
  • Легко забыть применить этот эффект к новым компонентам и настраиваемым интерактивным компонентам.
  • Собственный эффект может быть сложно комбинировать с другими эффектами.

Чтобы избежать этих проблем и легко масштабировать пользовательский компонент в системе, вы можете использовать Indication. Indication – это визуальный эффект, который можно использовать повторно и применять к компонентам в приложении или системе проектирования. 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() и визуализировать эффект масштабирования, используя те же команды рисования, что и в любом другом графическом API в Compose.

    Вызов drawContent(), доступный из получателя ContentDrawScope, нарисует фактический компонент, к которому должен быть применен 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. Если нажатие происходит во время существующего нажатия или анимации покоя, предыдущая анимация отменяется, а анимация нажатия начинается с начала. Чтобы поддерживать несколько одновременных эффектов (например, с рябью, когда новая анимация ряби будет рисоваться поверх других рябей), вы можете отслеживать анимацию в списке, вместо того чтобы отменять существующие анимации и запускать новые.